Python学习总结

1、第一天

Python优缺点

Python 语言的优点很多,简单为大家列出几点:

  1. 简单优雅,跟其他很多编程语言相比,Python 更容易上手
  2. 能用更少的代码做更多的事情,提升开发效率
  3. 开放源代码,拥有强大的社区和生态圈
  4. 能够做的事情非常多,有极强的适应性
  5. 胶水语言,能够黏合其他语言开发的东西
  6. 解释型语言,更容易跨平台,能够在多种操作系统上运行

Python 最主要的缺点是执行效率低(解释型语言的通病),如果更看重代码的执行效率,C、C++ 或 Go 可能是你更好的选择

2、第二天

print函数

复制代码
print('hello, world')

print函数可以去输出指定内容,print函数圆括号中的 'hello, world' 是一个字符串,它代表了一段文本内容;在python语言中可以使用单引号或双引号来表示一个字符串。不同于其他的编程语言,C、C++或 Java,python代码中的语句不需要分号来表示结束。也就是说,要是想要再去写一条语句,只需要回车就好了。此外,python代码的执行不需要通过编写入口函数(main)来使其运行,而提供 main 函数是执行C、C++ 或 Java 代码必须要做的事情,但是在Python语句中并不是必要的

代码注释

注释:代码中的重要组成部分,用于在代码中解释代码的作用,从而达到增强代码可读性的目标。当然也可以将代码中暂时不需要运行的代码段通过添加注释来去掉,这样等后面重新需要使用这段代码的时候,去掉注释符号就可以了。简单的说,注释会让代码更容易看懂但不会影响代码的执行结果

Python 中有两种形式的注释:

  1. 单行注释:以 # 和空格开头,可以注释掉从#开始后面一整行的内容
  2. 多行注释:三个引号(通常用双引号)开头,三个引号结尾,通常用于添加多行说明性内容

3、第三天

程序是数据和指令的有序集合,写程序就是用数据和指令控制计算机做想让它做的事情

计算机硬件系统

计算机的硬件系统通常由五大部件构成,包括:运算器、控制器、存储器、输入设备和输出设备。其中,运算器和控制器放在一起就是常说的中央控制器(CPU),它的功能就是执行各种运算和控制指令。存储器可以分为内部存储器和外部存储器,内部存储器就是内存,它是中央处理器可以直接寻址的存储空间,程序在执行的过程中,对应的数据和指令需要加载到内存中。输入设备和输出设备被称为I/O设备,键盘,鼠标,麦克风,摄像头都是典型性的输入设备,而显示器,打印机,扬声器等则是典型的输出设备。目前,使用的计算机基本大多是遵循 "冯·诺依曼体系结构" 的计算机,此类计算机有两个关键点:一是将存储器与中央处理器分开;二是将数据以二进制方式编码

变量和类型

在所有的编程语言中,变量是数据的载体。简单来说,就是一块用来保存数据的内存空间,变量的值可以被读取和修改,这是所有运算和控制的基础。计算机可以处理很多类型的数据,最常见的及时数值,除此以外还有文本、图像、音频、视频等各种的数据类型。虽然数据在计算机中是以二进制形态存在的,但是可以用不同类型的变量去表示数据类型之间的差异。Python语言中预设了很多种的数据类型,也允许自定义新的数据类型。下面是几种常见的数据类型

1、整型(int):Python 中可以处理任意大小的整数,而且支持二进制、八进制、十进制 和十六进制的表示法

复制代码
print(0b100)  # 二进制整数:4
print(0o100)  # 八进制整数:8
print(100)    # 十进制整数:100
print(0x100)  # 十六进制整数:256

2、浮点型(float):浮点数就是小数。按照科学计数法表示时,浮点数的小数点位置是可变的,所以被称为浮点数。浮点数除了数学写法(如123.456)之外,还支持科学计数法(如1.23456e2,表示 \\small{1.23456 \\times 10\^{2}}

复制代码
print(123.456)    # 数学写法
print(1.23456e2)  # 科学计数法

3、字符串型(str):字符串是以单引号或双引号包裹起来的任意文本,比如'hello'和"hello"

4、布尔型(bool):布尔型只有True、False两种值,要么是True,要么是False,如果一个变量的值只有两种状态,就可以使用布尔型

变量命名(见名知意)

对于每个变量,都需要有一个名字,在Python中,变量的命名需要遵循以下的规则和惯例

规则部分:

  • **规则1:**变量名由字母、数字和下划线构成,数字不能开头。这里的字母指的是Unicode 字符(Unicode 称为万国码),囊括了世界上大部分的文字系统,这就意味着中文,日文,希腊字母等都可以作为变量名中的字符,但是一些特殊字符(如:!、@、#等)是不能出现在变量名中的
  • **规则2:**Python对大小写比较敏感,简单来说就是大写的A和小写的a是两个不同的变量
  • **规则3:**变量名不要跟 Python 的关键字重名,尽可能避开 Python 的保留字。这里的关键字是指在 Python 程序中有特殊含义的单词(如:is、if、else、for、while等),保留字主要指 Python 语言内置函数、内置模块等的名字(如:int、print、input、str、math等)

惯例部分:

  • 惯例1:变量名通常使用小写英文字母,多个单词用下划线进行连接
  • 惯例2:受保护的变量用单个下划线开头
  • 惯例3:私有的变量用两个下划线开头

变量的使用

在Python中可以使用type函数对变量的类型进行检查,程序设计中的函数的概念和数学中的函数概念其实非常相似,都包含了函数名,自变量和因变量

复制代码
## 使用type函数检查变量的类型
a = 100
b = 123.45
c = 'hello, world'
d = True
print(type(a))  # <class 'int'>
print(type(b))  # <class 'float'>
print(type(c))  # <class 'str'>
print(type(d))  # <class 'bool'>

可以通过 Python 内置的函数来改变变量的类型,下面是一些常用的和变量类型相关的函数

  • int():将一个数值或字符串转换成整数,可以指定进制

  • float():将一个字符串(在可能的情况下)转换成浮点数

  • str():将指定的对象转换成字符串形式,可以指定编码方式

  • chr():将整数(字符编码)转换成对应的(一个字符的)字符串

  • ord():将(一个字符的)字符串转换成对应的整数(字符编码)

    变量的类型转换操作

    a = 100
    b = 123.45
    c = '123'
    d = '100'
    e = '123.45'
    f = 'hello, world'
    g = True
    print(float(a)) # int类型的100转成float,输出100.0
    print(int(b)) # float类型的123.45转成int,输出123
    print(int(c)) # str类型的'123'转成int,输出123
    print(int(c, base=16)) # str类型的'123'按十六进制转成int,输出291
    print(int(d, base=2)) # str类型的'100'按二进制转成int,输出4
    print(float(e)) # str类型的'123.45'转成float,输出123.45
    print(bool(f)) # str类型的'hello, world'转成bool,输出True
    print(int(g)) # bool类型的True转成int,输出1
    print(chr(a)) # int类型的100转成str,输出'd'
    print(ord('d')) # str类型的'd'转成int,输出100

说明:str类型转int类型时可以通过base参数来指定进制,可以将字符串视为对应进制的整数进行转换。str类型转成bool类型时,只要字符串有内容,不是''或"",对应的布尔值都是True。bool类型转int类型时,True会变成1,False会变成0。在 ASCII 字符集和 Unicode 字符集中, 字符'd'对应的编码都是100

4、第四天

Python中支持很多的运算符,有了变量和运算符就可以构造不同的表达式来解决实际问题。表达式是计算机程序中的句法实体,它由一个或多个常量、变量、函数和运算符组合而成,编程语言可以对其进行解释和计算以得到另一个值

说明: 所谓优先级就是在一个运算的表达式中,如果出现了多个运算符,应该先执行什么再执行什么的顺序。编写代码的时候,如果搞不清楚一个表达式中运算符的优先级,可以使用圆括号(小括号)来确保运算的执行顺序

算术运算符

除了常见的加、减、乘、除之外,还有求模(求余数)运算符和求幂运算符

算术运算是跟数学中没有区别,都是乘除的运算要优于加减法的,如果有求幂的运算,那么就要优于乘除法的。如果想改算术运算的执行顺序,可以使用英文输入法状态下的圆括号(小括号),写在圆括号中的表达式会被优先执行

赋值运算符

赋值运算符是最常见的运算符,作用是将右边的值赋给左边的变量。赋值运算符还可以跟上面的算术运算符放在一起,组合成复合赋值运算符。例如:a += b 相当于a = a + b,a *= a + 2 相当于a = a * (a + 2)

复制代码
## 赋值运算符和复合赋值运算符
a = 10
b = 3
a += b        # 相当于:a = a + b
a *= a + 2    # 相当于:a = a * (a + 2)
print(a)      

赋值运算构成的表达式本身不产生任何值,也就是说,如果把一个赋值表达式放到print函数中试图输出表达式的值,将会产生语法错误。为了解决这个问题,Python 3.8 中引入了一个新的赋值运算符 :=,称之为海象运算符。海象运算符也是将运算符右侧的值赋值给左边的变量,与赋值运算符不同的是,运算符右侧的值也是整个表达式的值

复制代码
## 海象运算符
SyntaxError: invalid syntax
print((a = 10))
海象运算符
print((a := 10))  # 10
print(a)          # 10

提示:上面代码如果不注释掉,运行代码会看到SyntaxError: invalid syntax错误信息,注意,这行代码中给a = 10加上了圆括号,如果不小心写成了print(a = 10),会看到TypeError: 'a' is an invalid keyword argument for print()错误信息

比较运算符和逻辑运算符

比较运算符也称为关系运算符,包括 ==、!=、<、>、<=、>=,比较不相等用的是!=,跟数学课本中使用的\\small{\\neq}并不相同,Python 2 中曾经使用过<>来表示不等于,在 Python 3 中使用<>会引发SyntaxError(语法错误)。比较运算符会产生布尔值,要么是True,要么是False

逻辑运算符有三个,分别and、or和not。and字面意思是"而且",所以and运算符会连接两个布尔值或者产生布尔值的表达式。如果两边的布尔值都是True,那么运算的结果就是True;此外的所有情况都是False(**and 短路处理:**如果and运算符左边的布尔值是False,无论不管右边的布尔值是什么,最终的结果都是False,这时运算符右边的布尔值会被跳过,如果and右边是一个表达式,那么这个表达式不会执行)。or字面意思是"或者",所以or运算符也会连接两个布尔值或产生布尔值的表达式,如果两边的布尔值有任意一个是True,那么最终的结果就是True。当然,or运算符也是有短路功能的,当它左边的布尔值为True的情况下,右边的布尔值会被短路(如果or右边是一个表达式,那么这个表达式不会执行)。not 运算符的后面可以跟一个布尔值,如果not后面的布尔值或表达式是True,那么运算的结果就是False;如果not后面的布尔值或表达式是False,那么运算的结果就是True

运算符和表达式应用

要求:输入华氏温度将其转换为摄氏温度,华氏温度到摄氏温度的转换公式为:C=( F - 32) / 1.8

复制代码
## 将华氏温度转换为摄氏温度
f = float(input('请输入华氏温度: '))
c = (f - 32) / 1.8
print('%.1f华氏度 = %.1f摄氏度' % (f, c))

input函数:用于于从键盘接收用户输入,由于输入的都是字符串,如果想处理成浮点小数来做后续的运算,需要用float函数将str类型处理成float类型

print输出的字符串中有两个%.1f占位符,这两个占位符会被%之后的(f, c)中的两个float类型的变量值给替换掉,浮点数小数点后保留1位有效数字。如果字符串中有%d占位符,那么会用int类型的值替换掉它,如果字符串中有%s占位符,那么它会被str类型的值替换掉

除了上面格式化输出的方式外,Python 中还可以用其他办法来格式化输出,一个带占位符的字符串,字符串前面的 f 表示这个字符串是需要格式化处理的,其中的 {f:.1f}{c:.1f} 可以先看成是**{f}** 和**{c}** ,表示输出时会用变量 f 和变量c 的值替换掉这两个占位符,后面的 :.1f表示这是一个浮点数,小数点后保留1位有效数字

复制代码
## 将华氏温度转换为摄氏温度
f = float(input('请输入华氏温度: '))
c = (f - 32) / 1.8
print(f'{f:.1f}华氏度 = {c:.1f}摄氏度')

例子2:计算圆的周长和面积

要求:输入一个圆的半径(\\small{r}),计算出它的周长( 2πr )和面积( πr2 )

里面有两个%,其中的含义不同。第一个%是占位符,(其中 % 是占位符的开头,f 代表浮点数,.2 代表保留两位小数)。第二个%,是一个运算符,负责执行填充的动作。它的准确名称是字符串格式化运算符,唯一的作用就是把右侧的变量值,填充到左侧字符串中指定的占位符位置

复制代码
## 输入半径计算圆的周长和面积
radius = float(input('请输入圆的半径: '))
perimeter = 2 * 3.1416 * radius
area = 3.1416 * radius * radius
print('周长: %.2f' % perimeter)
print('面积: %.2f' % area)

这里其实还有一种格式化输出的方式,假如变量a的值是9.87,那么字符串f'{a = }'的值是a = 9.87;而字符串f'{a = :.1f}'的值是a = 9.9。这种格式化输出的方式会同时输出变量名和变量值

复制代码
## 输入半径计算圆的周长和面积
import math
radius = float(input('请输入圆的半径: '))  # 输入: 5.5
perimeter = 2 * math.pi * radius
area = math.pi * radius ** 2
print(f'{perimeter = :.2f}')  # 输出:perimeter = 34.56
print(f'{area = :.2f}')       # 输出:area = 95.03

5、第五天

顺序结构:代码是一条一条语句按照顺序向下执行的

使用 if 和 else 构造分支结构

在 Python 中,构造分支结构最常用的是 if、elifelse三个关键字,所谓关键字就是编程语言中有特殊含义的单词,很显然不能够使用它作为变量名。当然也不是每次构造分支结构都会把三个关键字全部用上

复制代码
## BMI计算器
height = float(input('身高(cm):'))
weight = float(input('体重(kg):'))
bmi = weight / (height / 100) ** 2
print(f'{bmi = :.1f}')
if 18.5 <= bmi < 24:
print('你的身材很棒!')

不同于 C、C++、Java 等编程语言,Python 中没有用花括号来构造代码块而是使用缩进的方式来表示代码的层次结构,如果if条件成立的情况下需要执行多条语句,只要保持多条语句具有相同的缩进就可以了。换句话说,若干行连续的语句如果保持了相同的缩进,那么它们就属于同一个代码块,相当于是一个执行的整体。缩进可以使用任意数量的空格,但通常使用4个空格

使用match和case构造分支结构

Python 3.10 中增加了一种新的构造分支结构的方式,通过使用match和case 关键字,可以轻松的构造出多分支结构(代码作用完全相同,但是代码会显示得更加简单优雅)

复制代码
## HTTP 响应状态码识别
status_code = int(input('响应状态码: '))
match status_code:
case 400: description = 'Bad Request'
case 401: description = 'Unauthorized'
case 403: description = 'Forbidden'
case 404: description = 'Not Found'
case 405: description = 'Method Not Allowed'
case 418: description = 'I am a teapot'
case 429: description = 'Too many requests'
case _: description = 'Unknown Status Code'
print('状态码描述:', description)

**说明:**带有_的case语句在代码中起到通配符的作用,如果前面的分支都没有匹配上,代码就会来到case _。case _的是可选的,并非每种分支结构都要给出通配符选项。如果分支中出现了case _,它只能放在分支结构的最后面,如果它的后面还有其他的分支,那么这些分支将是不可达的

match-case语法还有很多高级玩法,其中有一个合并模式。例如,要将响应状态码401、403和404归入一个分支,400和405归入到一个分支,其他保持不变,代码还可以这么写

复制代码
status_code = int(input('响应状态码: '))
match status_code:
    case 400 | 405: description = 'Invalid Request'
    case 401 | 403 | 404: description = 'Not Allowed'
    case 418: description = 'I am a teapot'
    case 429: description = 'Too many requests'
    case _: description = 'Unknown Status Code'
print('状态码描述:', description)

根据实际开发需要,分支结构是可以嵌套的,也就是说在分支结构的if、elif或else代码块中还可以再次引入分支结构。例如if条件成立表示玩家过关,但过关以后还要根据你获得宝物或者道具的数量对你的表现给出评价(比如点亮一颗、两颗或三颗星星),那么就需要在if的内部再构造一个新的分支结构(称之为嵌套的分支结构)

6、第六天

循环结构:所谓的循环结构,就是程序中控制某条或某些指令重复执行的结构。Python语句中构造循环结构有两种做法,一种是for-in 循环,另一种是while循环

for-in循环

如果明确知道循环执行的次数,推荐使用for-in循环。被for-in循环控制的代码块(被称作循环体)也是通过缩进的方式来构造,通常循环体中的语句会根据循环的设定被重复执行

复制代码
## 每隔1秒输出一次"hello, world",持续1小时
import time
for i in range(3600):
print('hello, world')
time.sleep(1)

说明:Python 内置time模块的sleep函数可以实现程序的休眠,参数1表示休眠的秒数,可以使用int或float类型,例如0.05表示50毫秒

代码中的range(3600)可以构造出一个0到3599的范围,把这样一个范围放进for-in循环中,就可以通过前面的循环变量i依次取出从0到3599的整数,下面是range函数的其他使用方法:

  • range(101):可以用来产生0到100范围的整数,需要注意的是取不到101
  • range(1, 101):可以用来产生1到100范围的整数,相当于是左闭右开的设定,即[1, 101)
  • range(1, 101, 2):可以用来产生1到100的奇数,其中2是步长(跨度),即每次递增的值,101取不到
  • range(100, 0, -2):可以用来产生100到1的偶数,其中-2是步长(跨度),即每次递减的值,0取不到

上面的操作都没有用到循环变量i,对于不需要用到循环变量的for-in循环结构,通常可以将循环变量命名为_,不会对运行有变化,但是会更加专业

复制代码
## 每隔1秒输出一次"hello, world",持续1小时
import time
for _ in range(3600):
print('hello, world')
time.sleep(1)

while循环

如果想要构造循环结构但是又不能确定循环重复的次数,更加推荐使用while循环。while循环通过布尔值或能产生布尔值的表达式来控制循环,当布尔值或表达式的值为True时,循环体(while语句下方保持相同缩进的代码块)中的语句就会被重复执行,当表达式的值为False时,结束循环

复制代码
## 从1到100的整数求和
total = 0
i = 1
while i <= 100:
total += i
i += 1
print(total)

相较于for-in循环来说,while循环需要增加一个变量 i,使用这个变量来控制循环,比如:while i <=100 的条件,直到循环条件不再成立之后,代码会离开while循环

break和continue

如果把while 循环条件修改为True,让条件恒成立会怎么样?也就意味着如果不做特殊处理,循环是不会结束的,也就是常说的死循环。为了在i 的值超过 100 后让循环停下来,使用了break关键字,它的作用是终止循环结构的执行。需要注意的是,break只能终止它所在的那个循环

复制代码
## 从1到100的偶数求和
total = 0
i = 2
while True:
total += i
i += 2
if i > 100:
break
print(total) 

除了break之外,还有另外一个关键字continue,它可以用来放弃本次循环后续的代码直接让循环进入下一轮。下面的代码使用了continue关键字跳过了i是奇数的情况,只有i是偶数的前提下,才会执行到后面程序

复制代码
## 从1到100的偶数求和
total = 0
for i in range(1, 101):
if i % 2 != 0:
continue
total += i
print(total)

总结:可以用for和while关键字来构造循环结构。如果事先知道循环结构重复的次数,通常使用for循环;如果循环结构的重复次数不能确定,可以用while循环。此外,可以在循环结构中使用break终止循环,也可以在循环结构中使用continue关键字让循环结构直接进入下一轮次

7、第七天

想用一个变量来保存多个数据,用统一的代码对多个数据进行操作

创建列表

在 Python 中,列表是由一系列元素按特定顺序构成的数据序列,这就意味着如果定义一个列表类型的变量,可以用它来保存多个数据。在 Python 中,可以使用[]字面量语法来定义列表,列表中的多个元素用逗号进行分隔。如果使用type 函数来查看变量的类型,会返回列表类型

复制代码
items1 = [35, 12, 99, 68, 55, 35, 87]
items2 = ['Python', 'Java', 'Go', 'Kotlin']
items3 = [100, 12.3, 'Python', True]
print(items1)  # [35, 12, 99, 68, 55, 35, 87]
print(items2)  # ['Python', 'Java', 'Go', 'Kotlin']
print(items3)  # [100, 12.3, 'Python', True]
print(type(items1))  # <class 'list'>

# 列表中可以有重复元素,但是通常并不建议将不同类型的元素放在同一个列表中,主要是操作起来极为不便

因为列表可以保存多个元素,它是一种容器型的数据类型,所以在给列表类型的变量起名字时,变量名通常用 复数形式的单词

除此以外,还可以通过 Python 内置的list函数将其他序列变成列表。准确的说,list并不是一个普通的函数,它是创建列表对象的构造器

复制代码
items4 = list(range(1, 10))
items5 = list('hello')
print(items4)  # [1, 2, 3, 4, 5, 6, 7, 8, 9]
print(items5)  # ['h', 'e', 'l', 'l', 'o']
range(1, 10)会产生1到9的整数序列,给到list构造器中,会创建出由1到9的整数构成的列表。字符串是字符构成的序列,上面的list('hello')用字符串hello的字符作为列表元素,创建了列表对象。

列表的运算

+:可以实现两个列表的拼接,拼接运算会将两个列表中的元素连接起来放到一个列表中

*:可以实现列表的重复运算,* 运算符会将列表元素重复指定的次数

in / not in:可以判断一个元素在不在列表中

复制代码
items5 = [35, 12, 99, 45, 66]
items6 = [45, 58, 29]
items7 = ['Python', 'Java', 'JavaScript']
print(items5 + items6)  # [35, 12, 99, 45, 66, 45, 58, 29]
print(items6 + items7)  # [45, 58, 29, 'Python', 'Java', 'JavaScript']
items5 += items6
print(items5)  # [35, 12, 99, 45, 66, 45, 58, 29]
print(items6 * 3)  # [45, 58, 29, 45, 58, 29, 45, 58, 29]
print(items7 * 2)  # ['Python', 'Java', 'JavaScript', 'Python', 'Java', 'JavaScript']
print(29 in items6)  # True
print(99 in items6)  # False
print('C++' not in items7)     # True
print('Python' not in items7)  # False

索引运算 :通过在 中指定元素的位置来访问该元素。需要说明的是, 的元素位置可以是0到N - 1的整数,叫做正向索引。还可以是 -1 到 -N 的整数,被称为反向索引,其中N代表的是列表元素的个数。对于正向索引,0可以访问列表中的第一个元素,N - 1可以访问最后一个元素;对于反向索引,-1 可以访问列表中的最后一个元素,-N 可以访问第一个元素

复制代码
items8 = ['apple', 'waxberry', 'pitaya', 'peach', 'watermelon']
print(items8[0])   # apple
print(items8[2])   # pitaya
print(items8[4])   # watermelon
items8[2] = 'durian'
print(items8)      # ['apple', 'waxberry', 'durian', 'peach', 'watermelon']
print(items8[-5])  # 'apple'
print(items8[-4])  # 'waxberry'
print(items8[-1])  # watermelon
items8[-4] = 'strawberry'
print(items8)      # ['apple', 'strawberry', 'durian', 'peach', 'watermelon']

在使用索引运算的时候要避免出现索引越界的情况,因为对于只有五个元素的列表items8,有效的正向索引是0到4,有效的反向索引是-1到-5。如果访问 items85 或 items8-6,就会引发IndexError错误,导致程序崩溃,对应的错误信息是:list index out of range,翻译成中文就是"数组索引超出范围"

如果希望一次性访问列表中的多个元素,可以使用切片运算。切片运算是形如 start:end:stride 的运算符:其中 start 代表访问列表元素的起始位置,end 代表访问列表元素的终止位置(终止位置的元素无法访问),而 stride 则代表了跨度,简单的说就是位置的增量,比如访问的第一个元素在start位置,那么第二个元素就在start + stride位置,当然start + stride要小于end

如果start值等于0,那么在使用切片运算符时可以将其省略;如果end值等于N,N代表列表元素的个数,那么在使用切片运算符时可以将其省略;如果stride值等于1,那么在使用切片运算符时也可以将其省略

复制代码
print(items8[1:3:1])     # ['strawberry', 'durian']
print(items8[0:3:1])     # ['apple', 'strawberry', 'durian']
print(items8[0:5:2])     # ['apple', 'durian', 'watermelon']
print(items8[-4:-2:1])   # ['strawberry', 'durian']
print(items8[-2:-6:-1])  # ['peach', 'durian', 'strawberry', 'apple']
当跨度为负数时,切片运算是从右向左访问元素的
print(items8[1:3])     # ['strawberry', 'durian']
print(items8[:3:1])    # ['apple', 'strawberry', 'durian']
print(items8[::2])     # ['apple', 'durian', 'watermelon']
print(items8[-4:-2])   # ['strawberry', 'durian']
print(items8[-2::-1])  # ['peach', 'durian', 'strawberry', 'apple']
可以通过切片操作修改列表中的元素
items8[1:3] = ['x', 'o']
print(items8)  # ['apple', 'x', 'o', 'peach', 'watermelon']

元素的遍历

可以使用 for-in循环去逐个取出列表中的元素

方法一:在循环结构中通过索引运算,遍历列表元素

复制代码
languages = ['Python', 'Java', 'C++', 'Kotlin']
for index in range(len(languages)):
    print(languages[index])

# len函数可以获取列表元素的个数N
# 而range(N)则构成了从0到N-1的范围,刚好可以作为列表元素的索引

方法二:直接对列表做循环,循环变量就是列表元素的代表

复制代码
languages = ['Python', 'Java', 'C++', 'Kotlin']
for language in languages:
    print(language)

8、第八天

列表的方法

添加和删除元素

列表是一种可变容器,可以向容器中添加元素、可以从容器移除元素,也可以修改现有容器中的元素。可以使用 append 方法向列表中追加元素(追加指的是将元素添加到列表的末尾),使用insert方法向列表中插入元素(插入则是在指定的位置添加新元素)

复制代码
languages = ['Python', 'Java', 'C++']
languages.append('JavaScript')
print(languages)  # ['Python', 'Java', 'C++', 'JavaScript']
languages.insert(1, 'SQL')
print(languages)  # ['Python', 'SQL', 'Java', 'C++', 'JavaScript']

同时可以用 remove 方法从列表中删除指定元素。需要注意的是,如果要删除的元素并不在列表中,会引发ValueError错误导致程序崩溃,建议在删除元素时,先用之前讲过的成员运算做一个判断 。还可以使用 pop方法从列表中删除元素,pop方法默认删除列表中的最后一个元素,当然也可以给一个位置,删除指定位置的元素。在使用pop方法删除元素时,如果索引的值超出了范围,会引发IndexError异常,导致程序崩溃。除此之外,列表还有一个clear方法,可以清空列表中的元素

复制代码
languages = ['Python', 'SQL', 'Java', 'C++', 'JavaScript']
if 'Java' in languages:
    languages.remove('Java')
if 'Swift' in languages:
    languages.remove('Swift')
print(languages)  # ['Python', 'SQL', C++', 'JavaScript']
languages.pop()
temp = languages.pop(1)
print(temp)       # SQL
languages.append(temp)
print(languages)  # ['Python', C++', 'SQL']
languages.clear()
print(languages)  # []
pop方法删除元素时会得到被删除的元素,在上面代码中将pop方法删除的元素赋值给了名为temp的变量

如果 languages 列表中有多个'Python',那么用languages.remove('Python'),只会删除第一个'Python',不会去删除所有的'Python'

从列表中删除元素还有另外一种方式,使用 Python 中的del关键字,后面需要跟着删除的元素。这种方法本质上跟使用pop方法(指定索引删除元素)没有本质的区别,但后者会返回删除的元素,前者在性能上略优。因为del对应的底层字节码指令是DELETE_SUBSCR,而pop对应的底层字节码指令是CALL_METHOD和POP_TOP

复制代码
items = ['Python', 'Java', 'C++']
del items[1]
print(items)  # ['Python', 'C++']

元素排序和反转

列表的sort操作可以实现列表元素的排序,而reverse操作可以实现元素的反转

复制代码
items = ['Python', 'Java', 'C++', 'Kotlin', 'Swift']
items.sort()
print(items)  # ['C++', 'Java', 'Kotlin', 'Python', 'Swift']
items.reverse()
print(items)  # ['Swift', 'Python', 'Kotlin', 'Java', 'C++']

列表生成式

在 Python 中,列表还可以通过一种特殊的字面量语法来创建,这种语法叫做生成式

基础语法:表达式 for 变量 in 可迭代对象

复制代码
#有一个整数列表nums1,创建一个新的列表nums2,nums2中的元素是nums1中对应元素的平方
nums1 = [35, 12, 97, 64, 55]
nums2 = []
for num in nums1:
    nums2.append(num ** 2)
print(nums2)

# 使用列表生成式做同样的事情
nums1 = [35, 12, 97, 64, 55]
nums2 = [num ** 2 for num in nums1]
print(nums2)

# 使用列表生成式创建列表不仅仅代码简单优雅
# 而且性能上面也优于使用for-in循环和append方法向空列表中追加元素的方式

嵌套列表

Python 语言没有限定列表中的元素必须是相同的数据类型,也就是说一个列表中的元素可以任意的数据类型,当然也包括列表本身。如果列表中的元素也是列表,那么可以称之为嵌套的列表,嵌套的列表可以用来表示表格或数学上的矩阵

复制代码
# 例如:保存5个学生3门课程的成绩
scores = [[95, 83, 92], [80, 75, 82], [92, 97, 90], [80, 78, 69], [65, 66, 89]]
print(scores[0])
print(scores[0][1])

9、第九天

元组的定义与运算

元组:多个元素按照一定顺序构成的序列。元组和列表的不同之处在于,元组是不可变类型,这就意味着元组类型的变量一旦定义,其中的元素不能再添加或删除,而且元素的值也不能修改。如果试图修改元组中的元素,将引发TypeError错误,导致程序崩溃。定义元组通常使用形如(x, y, z)的字面量语法,元组类型支持的运算符跟列表是一样的

一个元组中如果有两个元素,称之为二元组;一个元组中如果五个元素,称之为五元组。需要注意的是,()表示空元组,但是如果元组中只有一个元素,需要加上一个逗号,否则()就不是代表元组的字面量语法,而是改变运算优先级的圆括号,所以('hello', )和(100, )才是一元组,而('hello')和(100)只是字符串和整数

复制代码
a = ()
print(type(a))  # <class 'tuple'>
b = ('hello')
print(type(b))  # <class 'str'>
c = (100)
print(type(c))  # <class 'int'>
d = ('hello', )
print(type(d))  # <class 'tuple'>
e = (100, )
print(type(e))  # <class 'tuple'>

打包和解包操作

当把多个用逗号分隔的值赋给一个变量时,多个值会打包成一个元组类型;当把一个元组赋值给多个变量时,元组会解包成多个值然后分别赋给对应的变量

复制代码
# 打包操作
a = 1, 10, 100
print(type(a))  # <class 'tuple'>
print(a)        # (1, 10, 100)

# 解包操作
i, j, k = a
print(i, j, k)  # 1 10 100

在解包时,如果解包出来的元素个数和变量个数不对应,会引发ValueError异常,错误信息为:too many values to unpack(解包的值太多)或 not enough values to unpack(解包的值不足)

复制代码
a = 1, 10, 100, 1000
# i, j, k = a             # ValueError: too many values to unpack (expected 3)
# i, j, k, l, m, n = a    # ValueError: not enough values to unpack (expected 6, got 4)

有一种解决变量个数少于元素的个数方法,就是使用星号表达式。通过星号表达式,可以让一个变量接收多个值。需要注意两点:首先,用星号表达式修饰的变量会变成一个列表,列表中有0个或多个元素;其次,在解包语法中,星号表达式只能出现一次

复制代码
a = 1, 10, 100, 1000
i, j, *k = a
print(i, j, k)        # 1 10 [100, 1000]
*i, j = a
print(i, j)           # [1, 10, 100] 1000
i, j, k, *l = a
print(i, j, k, l)     # 1 10 100 [1000]
i, j, k, l, *m = a
print(i, j, k, l, m)  # 1 10 100 1000 []

需要说明一点,解包语法对所有的序列都成立,这就代表列表、range函数构造的范围序列甚至字符串都可以使用解包语法

复制代码
a, b, *c = range(1, 10)
print(a, b, c)            #1 2 [3, 4, 5, 6, 7, 8, 9]
a, b, c = [1, 10, 100]
print(a, b, c)            #1 10 100
a, *b, c = 'hello'
print(a, b, c)            #h ['e', 'l', 'l'] o

交换变量的值

交换变量的值是写代码中经常用到的操作,但是其他的编程语言中,交换两个变量的值需要借助一个中间变量才能做到,不然就需要使用比较晦涩的位运算来实现。但是在Python中交换两个变量a和b的值会比较简单,用到如下的代码:

复制代码
a, b = b, a
同理,如果要将三个变量a、b、c的值互换,即b的值赋给a,c的值赋给b,a的值赋给c
a, b, c = b, c, a

上面的操作并没有用到打包和解包语法,Python 的字节码指令中有ROT_TWO 和 ROT_THREE这样的指令可以直接实现这个操作,效率是非常高的。但是如果有多于三个变量的值要依次互换,这个时候是没有直接可用的字节码指令的,需要通过打包解包的方式来完成变量之间值的交换

元组和列表的比较

抛出一个问题,Python中已经有了列表类型,为什么还需要有元组这样的类型呢?

  • 元组是不可变类型,不可变类型更适合多线程环境,因为它降低了并发访问变量的同步化开销

  • 元组是不可变类型,通常不可变类型在创建时间上优于对应的可变类型 。可以使用 timeit 模块的 timeit 函数来看看创建保存相同元素的元组和列表各自花费的时间,timeit 函数的number参数表示代码执行的次数

    import timeit
    print('%.3f 秒' % timeit.timeit('[1, 2, 3, 4, 5, 6, 7, 8, 9]', number=10000000))
    print('%.3f 秒' % timeit.timeit('(1, 2, 3, 4, 5, 6, 7, 8, 9)', number=10000000))
    输出:
    0.415 秒
    0.090 秒

不过,Python 中的元组和列表类型是可以相互转换的

复制代码
infos = ('78', 45, True, '四川成都')        # 将元组转换成列表
print(list(infos))  # ['78', 45, True, '四川成都']
frts = ['apple', 'banana', 'orange']        # 将列表转换成元组
print(tuple(frts))  # ('apple', 'banana', 'orange')

总结:列表和元组都是容器型的数据类型,即一个变量可以保存多个数据,而且都是按一定顺序组织元素的有序容器。列表是可变数据类型,元组是不可变数据类型,所以列表可以添加元素、删除元素、清空元素、排序反转,但这些操作对元组来说是不成立的。列表和元组都可以支持拼接运算、成员运算、索引运算、切片运算等操作。推荐使用列表的生成式语法来创建列表,不仅好用而且效率很高,是非常有特色的语法

10、第十天

所谓字符串 ,就是由零个或多个字符组成的有限序列,一般记为:s=a1a2⋯an,,,,,(0≤n≤∞)。在 Python 程序中,单个或多个字符用单引号或者双引号包围起来,就可以表示一个字符串。字符串中的字符可以是特殊符号、英文字母、中文字符、日文的平(片)假名、希腊字母、Emoji 字符等

转义字符

\(反斜杠):通常在字符串中来表示转义,也就是说\后面的字符不再是原来的意义

  • \n:不是代表字符\和字符n,而是表示换行
  • \t :不是代表字符\和字符t,而是表示制表符

所以如果字符串本身又包含了'、"、\这些特殊的字符,必须要通过\进行转义处理

复制代码
# 输出一个带单引号或反斜杠的字符串,需要用如下所示的方法
s1 = ''hello, world!''
s2 = '\hello, world!\'
print(s1)
print(s2)

原始字符串

原始字符串:一种以r或R开头的字符串,意思是字符串中的每个字符都是它本来的含义,没有所谓的转义字符。例如在字符串'hello\n'中,\n表示换行;而在r'hello\n'中,\n不再表示换行,就是字符\和字符n

复制代码
s1 = '\it \is \time \to \read \now'
s2 = r'\it \is \time \to \read \now'
print(s1)
print(s2)

# 变量s1中,\t、\r和\n都是转义字符,\t是制表符(table),\n是换行符(new line),\r是回车符相当于让输出回到了行首

字符的特殊表示

Python中运行在\后面跟一个八进制或者十六进制数来表示字符,例如\141和\x61都代表小写字母a,前者是八进制的表示法,后者是十六进制的表示法。另外一种表示字符的方式是在\u后面跟 Unicode 字符编码

复制代码
s1 = '\141\142\143\x61\x62\x63'
print(s1)

字符串的运算

拼接和重复

  • 可以使用+运算符来实现字符串的拼接

  • 可以使用*运算符来重复一个字符串的内容

    s1 = 'hello' + ', ' + 'world'
    print(s1) # hello, world
    s2 = '!' * 3
    print(s2) # !!!
    s1 += s2
    print(s1) # hello, world!!!
    s1 *= 2
    print(s1) # hello, world!!!hello, world!!!

    用 * 实现字符串的重复是非常有意思的一个运算符

    在很多编程语言中,要表示一个有10个a的字符串,只能写成'aaaaaaaaaa'

    但是在 Python 中,可以写成'a' * 10

比较运算

对比两个字符串类型的变量,可以直接使用 比较运算符来判断两个字符串的相等性或比较大小。需要说明的是,字符串的大小比较比的是每个字符对应的编码的大小。例如A的编码是65, 而a的编码是97,所以'A' < 'a'的结果相当于就是65 < 97的结果,这里很显然是True;而'boy' < 'bad',因为第一个字符都是'b'比不出大小,所以实际比较的是第二个字符的大小,显然'o' < 'a'的结果是False,所以'boy' < 'bad'的结果是False,可以使用ord函数去获得两个字符对应的编码是多少

成员运算

pyton中可以使用in和not in来判断一个字符串是否包含另外一个字符串,跟列表类型一样,in和not in称为成员运算符,会产生布尔值True或False

复制代码
s1 = 'hello, world'
s2 = 'goodbye, world'
print('wo' in s1)      # True
print('wo' not in s2)  # False
print(s2 in s1)        # False

获取字符串长度

获取字符串长度跟获取列表元素个数一样,使用内置函数 len

复制代码
s = 'hello, world'
print(len(s))                 # 12
print(len('goodbye, world'))  # 14

索引和切片

字符串的索引和切片操作跟列表、元组几乎没有区别,因为字符串也是一种有序序列,可以通过正向或反向的整数索引访问其中的元素。但是有一点需要注意,因为字符串是不可变类型,所以不能通过索引运算修改字符串中的字符。在进行索引运算时,如果索引越界,会引发 IndexError 异常,错误提示信息为:string index out of range(字符串索引超出范围)

复制代码
s = 'abc123456'
n = len(s)
print(s[0], s[-n])    # a a
print(s[n-1], s[-1])  # 6 6
print(s[2], s[-7])    # c c
print(s[-7:-4])       # c12
print(s[2:])          # c123456
print(s[:2])          # ab
print(s[::2])         # ac246
print(s[::-1])        # 654321cba

# 可以用[]和[:]运算符从字符串取出某个字符或某些字符

字符的遍历

如果希望遍历字符串中的每个字符,可以使用for-in循环,有两种方式:

复制代码
# 方式一:
s = 'hello'
for i in range(len(s)):
print(s[i])

# 方式二:
s = 'hello'
for elem in s:
print(elem)

字符串的方法

大小写相关操作

复制代码
s1 = 'hello, world!'
# 字符串首字母大写
print(s1.capitalize())  # Hello, world!
# 字符串每个单词首字母大写
print(s1.title())       # Hello, World!
# 字符串变大写
print(s1.upper())       # HELLO, WORLD!
s2 = 'GOODBYE'
# 字符串变小写
print(s2.lower())       # goodbye
# 检查s1和s2的值
print(s1)               # hello, world
print(s2)               # GOODBYE

# 由于字符串是不可变类型,使用字符串的方法对字符串进行操作会产生新的字符串,但是原来变量的值并没有发生变化
# 所以上面的代码中,当最后检查s1和s2两个变量的值时,s1和s2的值并没有发生变化

查找操作

如果想在一个字符串中从前向后查找有没有另外一个字符串,可以使用字符串的 find 或 index 方法。在使用find和index方法时还可以通过方法的参数来指定查找的范围,也就是查找不必从索引为0的位置开始。find方法找不到指定的字符串会返回 -1 ,index方法找不到指定的字符串会引发 ValueError错误

复制代码
s = 'hello, world!'
print(s.find('or'))      # 8
print(s.find('or', 9))   # -1
print(s.find('of'))      # -1
print(s.index('or'))     # 8
print(s.index('or', 9))  # ValueError: substring not found

find和index方法还有逆向查找(从后向前查找)的版本,分别是 rfind 和 rindex

复制代码
s = 'hello world!'
print(s.find('o'))       # 4
print(s.rfind('o'))      # 7
print(s.rindex('o'))     # 7
# print(s.rindex('o', 8))  # ValueError: substring not found

性质判断

可以通过字符串的startswith、endswith来判断字符串是否以某个字符串开头和结尾;还可以用is开头的方法判断字符串的特征,这些方法都返回布尔值

复制代码
s1 = 'hello, world!'
print(s1.startswith('He'))   # False
print(s1.startswith('hel'))  # True
print(s1.endswith('!'))      # True
s2 = 'abc123456'
print(s2.isdigit())  # False        
print(s2.isalpha())  # False        
print(s2.isalnum())  # True

# isdigit用来判断字符串是不是完全由数字构成的
# isalpha用来判断字符串是不是完全由字母构成的,这里的字母指的是 Unicode 字符但不包含 Emoji 字符
# isalnum用来判断字符串是不是由字母和数字构成的

格式化

在 Python 中,字符串类型可以通过center、ljust、rjust方法做居中、左对齐和右对齐的处理。如果要在字符串的左侧补零,也可以使用zfill方法

复制代码
s = 'hello, world'
print(s.center(20, '*'))  # ****hello, world****
print(s.rjust(20))        #         hello, world
print(s.ljust(20, '~'))   # hello, world~~~~~~~~
print('33'.zfill(5))      # 00033
print('-33'.zfill(5))     # -0033

针对print函数输出字符串时,可以用下面的方式进行格式化

复制代码
a = 321
b = 123
print('%d * %d = %d' % (a, b, a * b))

# 可以用字符串的format方法来完成字符串的格式
a = 321
b = 123
print('%d * %d = %d' % (a, b, a * b))

# 还有更简洁的方式:在字符串前加上f来格式化字符串,在这种以f打头的字符串中,{变量名}是一个占位符,会被变量对应的值将其替换掉
a = 321
b = 123
print(f'{a} * {b} = {a * b}')

修剪操作

strip方法:将原字符串修剪掉左右两端指定字符,默认是修剪空格字符,可以用于将用户输入时不小心键入的头尾空格等去掉。还可以使用lstrip和rstrip

复制代码
s1 = '   jackfrued@126.com  '
print(s1.strip())      # jackfrued@126.com
s2 = '~你好,世界~'
print(s2.lstrip('~'))  # 你好,世界~
print(s2.rstrip('~'))  # ~你好,世界

替换操作

replace方法:用新的内容替换字符串中指定的内容。此方法的第一个参数是被替换的内容,第二个参数是替换后的内容,还可以通过第三个参数指定替换的次数

复制代码
s = 'hello, good world'
print(s.replace('o', '@'))     # hell@, g@@d w@rld
print(s.replace('o', '@', 1))  # hell@, good world

拆分与合并

spilt方法:将一个字符串拆分为多个字符串(放在一个列表中)

join方法:将列表中的多个字符串连接成一个字符串
需要说明的是,split 方法默认使用空格进行拆分,也可以指定其他的字符来拆分字符串,而且还可以指定最大拆分次数来控制拆分的效果

复制代码
s = 'I love you'
words = s.split()
print(words)            # ['I', 'love', 'you']
print('~'.join(words))  # I~love~you
s = 'I#love#you#so#much'
words = s.split('#')
print(words)  # ['I', 'love', 'you', 'so', 'much']
words = s.split('#', 2)
print(words)  # ['I', 'love', 'you#so#much']

11、第十一天

Python中的集合强调的是无序性和互异性。无序性说明集合中的元素不存在某种次序,不支持通过索引运算去访问元素。另外集合所具备的互异性也决定了集合中不能有重复元素,无法将重复的元素添加到一个集合中。集合的成员运算在性能上要优于列表的成员运算,这是集合的底层存储特性决定的

创建集合

在 Python 中,创建集合可以使用{}字面量语法,{}中需要至少有一个元素,因为没有元素的{}并不是空集合而是一个空字典,也可以使用Python 内置函数set来创建一个集合(set:创建集合对象的构造器)。可以使用set函数创建一个空集合,也可以用它将其他序列转换成集合,例如:set('hello')会得到一个包含了4个字符的集合(重复的字符l只会在集合中出现一次)。除了这两种方式,还可以使用生成式语法来创建集合

复制代码
set1 = {1, 2, 3, 3, 3, 2}
print(set1)    # {1, 2, 3}
set2 = {'banana', 'pitaya', 'apple', 'apple', 'banana', 'grape'}
print(set2)    # {'banana', 'pitaya', 'apple', 'grape'}
set3 = set('hello')
print(set3)    # {'e', 'h', 'l', 'o'}
set4 = set([1, 2, 2, 3, 3, 3, 2, 1])
print(set4)    # {1, 2, 3}
set5 = {num for num in range(1, 20) if num % 3 == 0 or num % 7 == 0}
print(set5)    # {3, 6, 7, 9, 12, 14, 15, 18}

需要注意的是,集合中的元素必须是hashable类型,所谓hashable类型指的是能够计算出哈希码的数据类型,通常不可变类型都是hashable类型,如整数、浮点小数、布尔值、字符串、元组(tuple)等。可变类型都不是hashable类型,因为可变类型无法计算出确定的哈希码,所以它们不能放到集合中。例如:不能将列表作为集合中的元素;同理,由于集合本身也是可变类型,所以集合也不能作为集合中的元素。可以构建出嵌套列表,但是没有嵌套的集合

集合元素的遍历

len函数:获得集合中有多少个元素,但是不能通过索引运算来遍历集合中的元素,因为集合没有特定的顺序。当然,要实现集合元素的遍历,仍然可以使用for-in循环

复制代码
set1 = {'Python', 'C++', 'Java', 'Kotlin', 'Swift'}
for elem in set1:
    print(elem)

#输出的结果是无序的,也就是说每次运行的结果可能不固定

集合的运算

成员运算:可以通过成员运算in和not in 检查元素是否在集合中

复制代码
set2 = {'Python', 'Java', 'C++', 'Swift'}
print('Ruby' in set2)  # False
print('Java' in set2)  # True

二元运算:主要指集合的交集、并集、差集、对称差等运算,这些运算可以通过运算符来实现,也可以通过集合类型的方法来实现

复制代码
set1 = {1, 2, 3, 4, 5, 6, 7}
set2 = {2, 4, 6, 8, 10}

# 交集
print(set1 & set2)                      # {2, 4, 6}
print(set1.intersection(set2))          # {2, 4, 6}

# 并集
print(set1 | set2)                      # {1, 2, 3, 4, 5, 6, 7, 8, 10}
print(set1.union(set2))                 # {1, 2, 3, 4, 5, 6, 7, 8, 10}

# 差集
print(set1 - set2)                      # {1, 3, 5, 7}
print(set1.difference(set2))            # {1, 3, 5, 7}

# 对称差
print(set1 ^ set2)                      # {1, 3, 5, 7, 8, 10}
print(set1.symmetric_difference(set2))  # {1, 3, 5, 7, 8, 10}

可以看出,& 和 intersection方法的作用是完全相同的,使用运算符的方式显然更直观且代码也更简短。而且,集合的二元运算还可以跟赋值运算一起构成复合赋值运算,例如:set1 |= set2 相当于set1 = set1 | set2,跟 |=作用相同的方法是update;set1 &= set2相当于set1 = set1 & set2,跟&=作用相同的方法是intersection_update

比较运算:两个集合可以用==和!=进行相等判断,如果两个集合中的元素完全相同,那么==比较的结果就是True,否则就是False。如果集合A的任意一个元素都是集合B的元素,那么集合A称为集合B的子集,即对于 ∀a∈A ,均有 a∈B,则 A⊆B ,A是B的子集。反过来也可以称B是A的超集。如果A是B的子集且A不等于B,那么A就是B的真子集。Python中为集合类型提供了子集运算符【<(真子集)、<=(子集)、>(真超集)、>=(超集)】,也可以通过集合类型的方法issubset 和 issuperset 来判断集合之间的关系

复制代码
set1 = {1, 3, 5}
set2 = {1, 2, 3, 4, 5}
set3 = {5, 4, 3, 2, 1}
print(set1 < set2)   # True
print(set1 <= set2)  # True
print(set2 < set3)   # False
print(set2 <= set3)  # True
print(set2 > set1)   # True
print(set2 == set3)  # True
print(set1.issubset(set2))    # True
print(set2.issuperset(set1))  # True

集合的方法

Python 中的集合是可变类型,可以通过集合的方法向集合添加元素或从集合中删除元素

复制代码
set1 = {1, 10, 100}

# 添加元素
set1.add(1000)    
print(set1)  # {1, 100, 1000, 10}

# 删除元素
set1.discard(10)    
if 100 in set1:
set1.remove(100)
print(set1)  # {1, 1000, 10000}

# 清空元素
set1.clear()
print(set1)  # set()

删除元素的remove方法在元素不存在时会引发KeyError错误,所以先通过成员运算判断元素是否在集合中。集合类型中pop方法可以从集合中随机删除一个元素,该方法在删除元素的同时会返回(获得)被删除的元素,而remove和discard方法仅仅是删除元素,不会返回被删除的元素

不可变集合

Python 中还有一种不可变类型的集合,名字叫frozenset。frozenset由于是不可变类型,能够计算出哈希码,因此它可以作为set中的元素。frozenset不能添加和删除元素,不过其他方面跟set是一样的

复制代码
fset1 = frozenset({1, 3, 5, 7})
fset2 = frozenset(range(1, 6))
print(fset1)          # frozenset({1, 3, 5, 7})
print(fset2)          # frozenset({1, 2, 3, 4, 5})
print(fset1 & fset2)  # frozenset({1, 3, 5})
print(fset1 | fset2)  # frozenset({1, 2, 3, 4, 5, 7})
print(fset1 - fset2)  # frozenset({7})
print(fset1 < fset2)  # False

总结:Python 中的集合类型是一种无序容器,不允许有重复运算,由于底层使用了哈希存储,集合中的元素必须是hashable类型。集合与列表最大的区别在于集合中的元素没有顺序、所以不能够通过索引运算访问元素、但是集合可以执行交集、并集、差集等二元运算,也可以通过关系运算符检查两个集合是否存在超集、子集等关系

12、第十二天

创建和使用字典

Python 中创建字典可以使用{}字面量语法,并且字典的{}中的元素是以键值对的形式存在的,每个元素由**:** 分隔的两个值构成,: 前面是键,: 后面是值。字典在保存信息方面,要远远优于使用列表或元组,可以用**:** 前面的键来表示条目的含义,而 : 后面就是这个条目所对应的值

复制代码
xinhua = {
    '麓': '山脚下',
    '路': '道,往来通行的地方;方面,地区:南~货,外~货'
}
print(xinhua)

当然,也可以使用内置函数 dict 或者是字典的生成式语法来创建字典

复制代码
# dict 函数(构造器)中的每一组参数就是字典中的一组键值对
person = dict(name='王大锤', age=55, height=168, 
              weight=60, addr='成都市武侯区科华北路62号1栋101')
print(person) 

# 可以通过Python内置函数zip压缩两个序列并创建字典
items1 = dict(zip('ABCDE', '12345'))
print(items1)  # {'A': '1', 'B': '2', 'C': '3', 'D': '4', 'E': '5'}

# 用字典生成式语法创建字典
items3 = {x: x ** 3 for x in range(1, 6)}
print(items3)  # {1: 1, 2: 8, 3: 27, 4: 64, 5: 125}

len函数:判断字典中一共有多少组键值对;如果是对字典进行遍历,可以用for循环,但是需要注意,for循环只是对字典的键进行了遍历

复制代码
person = {
    'name': '王大锤',
    'age': 55,
    'height': 168,
    'weight': 60
}
print(len(person))  # 4
for key in person:
    print(key)

字典的运算

对于字典类型来说,成员运算和索引运算是很重要的,前者判定指定的键是否在字典中,后者可以通过键访问对应的值或者向字典中添加新的键值对。因为字典中保存的是键值对,所以字典需要用键去索引对应的值,同时字典中的键必须是不可变类型,例如整数、浮点数、字符串、元组等类型,之前了解到的列表和集合不能作为字典中的键,字典类型本身也不能再作为字典中的键,因为字典也是可变类型,但是列表、集合、字典都可以作为字典中的值。不过在通过索引运算获取字典中的值时,如指定的键没有在字典中,将会引发KeyError异常

复制代码
person = {
    'name': '王大锤',
    'car': {
        'brand': 'BMW X7',
        'maxSpeed': '250',
        'length': 5170,
        'displacement': 3.0
    }
}
print(person)

字典的方法(键值对相关)

get方法:通过键来获取对应的值,跟索引运算不同的是,get方法在字典中没有指定的键时不会产生异常,而是返回None或指定的默认值

复制代码
person = {'name': '王大锤', 'age': 25, 
          'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}
print(person.get('name'))       # 王大锤
print(person.get('sex'))        # None
print(person.get('sex', True))  # True
  • keys方法:可以获取字典中所有的键

  • values方法:可以获取字典中所有的值

  • items:会将键和值组装成二元组

    person = {'name': '王大锤', 'age': 25, 'height': 178}
    print(person.keys()) # dict_keys(['name', 'age', 'height'])
    print(person.values()) # dict_values(['王大锤', 25, 178])
    print(person.items()) # dict_items([('name', '王大锤'), ('age', 25), ('height', 178)])
    for key, value in person.items():
    print(f'{key}:\t{value}')

update方法:两个字典的合并操作

复制代码
# 两个字典x和y,当执行x.update(y)操作时
# x跟y相同的键对应的值会被y中的值更新,而y中有但x中没有的键值对会直接添加到x中
person1 = {'a': '1', 'b': 2, '3': 3}
person2 = {'4': 4, '5': '5'}
person1.update(person2)
print(person1)  # {'a': '1', 'b': 2, '3': 3,'4': 4, '5': '5'}

# 如果是Python 3.9版本,也可以使用|运算符来完成同样的操作
person1 |= person2
print(person1)   # {'a': '1', 'b': 2, '3': 3,'4': 4, '5': '5'}

pop 或 popitem方法:都是从字典中删除元素,不同的是前者会返回键对应的值,但是如果字典中不存在指定的键,会引发KeyError 错误;后者在删除元素时,会返回键和值组成的二元组

clear方法:会清空字典中所有的键值对

复制代码
person = {'name': '王大锤', 'age': 25, 
          'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}
print(person.pop('age'))  # 25
print(person)             # {'name': '王大锤', 'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}
print(person.popitem())   # ('addr', '成都市武侯区科华北路62号1栋101')
print(person)             # {'name': '王大锤', 'height': 178}
person.clear()
print(person)             # {}

跟列表一样,从字典中删除元素也可以使用del关键字,在删除元素的时候如果指定的键索引不到对应的值,一样会引发KeyError错误

复制代码
person = {'name': '王大锤', 'age': 25, 'height': 178, 'addr': '成都市武侯区科华北路62号1栋101'}
del person['age']
del person['addr']
print(person)  # {'name': '王大锤', 'height': 178}

总结:Python 程序中的字典允许以键值对的形式保存数据,再通过键访问对应的值。字典是一种非常有利于数据检索的数据类型,但是字典中的键必须是不可变类型,列表、集合、字典等类型的数据都不能作为字典的键

13、第十三天

数学上的函数通常形如 y=f(x) 或者 z=g(x,y) 这样的形式,在 y=f(x) 中, f 是函数的名字, x 是函数的自变量, y 是函数的因变量;而在 z=g(x,y) 中, g 是函数名, x 和 y 是函数的自变量, z 是函数的因变量。Python 中的函数结构也是一致的,每个函数都有自己的名字、自变量和因变量。通常把 Python 函数的自变量称为函数的参数,而因变量称为函数的返回值

Python 中使用 def关键字来定义函数,函数也应该有一个名字,命名规则跟变量的命名规则是一样的。在函数名后面的圆括号中可以设置函数的参数,也就是函数的自变量,而函数执行完成后,会通过return关键字来返回函数的执行结果,也就是因变量。如果函数中没有return语句,那么函数会返回代表空值的None。此外,函数是可以没有自变量(参数),但是函数名后面的圆括号是必须有的。一个函数要做的事情,是通过代码缩进的方式放到函数定义行之后

函数可以将功能相对独立且会被重复使用的代码封装起来,当需要这些的代码,不是把重复的代码再编写一遍,而是通过调用函数实现对既有代码的复用。后续使用的函数,要么是自定义的函数,要么是 Python 标准库或者三方库中提供的函数。如果已经有现成的可用函数,就没用必要去"重复发明轮子"。如果觉得某些函数名太长党的话,可以在导入函数的时候,通过as关键字为其别名。在调用函数的时候,可以用函数的别名,而不再使用它之前的名字

复制代码
# 输入m和n,计算组合数C(m,n)的值
from math import factorial as f

m = int(input('m = '))
n = int(input('n = '))
print(f(m) // f(n) // f(m - n))

函数的参数

位置参数和关键字参数

make_judgement 函数有三个参数,这种参数叫做位置参数,在调用函数时通常按照从左到右的顺序依次传入,而且传入参数的数量必须和定义函数时参数的数量相同。如果不想按照从左到右的顺序依次给出a、b、c 三个参数的值,也可以使用关键字参数,通过"参数名=参数值"的形式为函数传入参数

复制代码
# 根据给出的三条边的长度判断是否可以构成三角形,如果可以构成三角形则返回True,否则返回False

def make_judgement(a, b, c):
    """判断三条边的长度能否构成三角形"""
    return a + b > c and b + c > a and a + c > b

print(make_judgement(1, 2, 3))  # False
print(make_judgement(4, 5, 6))  # True

print(make_judgement(b=2, c=3, a=1))  # False
print(make_judgement(c=6, b=4, a=5))  # True

在定义函数时,可以在参数列表中用 **/**设置强制位置参数(positional-only arguments),用 * 设置命名关键字参数。所谓强制位置参数,就是调用函数时只能按照参数位置来接收参数值的参数;而命名关键字参数只能通过"参数名=参数值"的方式来传递和接收参数

复制代码
# /前面的参数是强制位置参数,且必须放在未知参数的末尾
def make_judgement(a, b, c, /):
    return a + b > c and b + c > a and a + c > b

# 下面的代码会产生TypeError错误,错误信息提示"强制位置参数是不允许给出参数名的"
# print(make_judgement(b=2, c=3, a=1))

# *后面的参数是命名关键字参数
def make_judgement(*, a, b, c):
    return a + b > c and b + c > a and a + c > b

# 下面的代码会产生TypeError错误,错误信息提示"函数没有位置参数但却给了3个位置参数"
# print(make_judgement(1, 2, 3))

数的默认值

允许函数有默认值

复制代码
from random import randrange

# 定义摇色子的函数
# 函数的参数 n表示色子的个数,默认值为2
# 函数的返回值表示摇n颗色子得到的点数
def roll_dice(n=2):
    total = 0
    for _ in range(n):
        total += randrange(1, 7)
    return total

print(roll_dice()) # 如果没有指定参数,那么n使用默认值2,表示摇两颗色子
print(roll_dice(3)) # 传入参数3,变量n被赋值为3,表示摇三颗色子获得点数

需要注意的是,带默认值的参数必须放在不带默认值的参数之后,否则将产生SyntaxError错误,错误消息是:non-default argument follows default argument,翻译成中文的意思是"没有默认值的参数放在了带默认值的参数后面"

可变参数

Python中可以通过星号表达式语法让函数支持可变参数。所谓可变参数指的是在调用函数时,可以向函数传入0个或任意多个参数。可变函数可以在不知道函数的调用者会向该函数传入多少个参数时,会排上用场

复制代码
# 用星号表达式来表示args可以接收0个或任意多个参数
# 调用函数时传入的n个参数会组装成一个n元组赋给args
# 如果一个参数都没有传入,那么args会是一个空元组

def add(*args):
    total = 0
    for val in args:    # 对保存可变参数的元组进行循环遍历
        if type(val) in (int, float):    # 对参数进行了类型检查(数值型的才能求和)
            total += val
    return total

# 在调用add函数时可以传入0个或任意多个参数
print(add())         # 0
print(add(1))        # 1
print(add(1, 2, 3))  # 6
print(add(1, 2, 'hello', 3.45, 6))  # 12.45

如果希望通过 "参数名=参数值" 的形式传入若干个参数,具体有多少个参数也是不确定的,还可以给函数添加可变关键字参数,把传入的关键字参数组装到一个字典中

复制代码
# 参数列表中的**kwargs可以接收0个或任意多个关键字参数
# 调用函数时传入的关键字参数会组装成一个字典(参数名是字典中的键,参数值是字典中的值)
# 如果一个关键字参数都没有传入,那么kwargs会是一个空字典

def foo(*args, **kwargs):
    print(args)
    print(kwargs)

foo(3, 2.1, True, name='小白', age=43, gpa=4.95)

# 输出:
(3, 2.1, True)
{'name': '小白', 'age': 43, 'gpa': 4.95}

用模块管理函数

如果遇到命名冲突的情况,比如说最简单的场景就是在同一个 .py 文件定义了两个同名的函数

复制代码
def foo():
    print('hello, world!')

def foo():
    print('goodbye, world!')
    
foo()  

如果在团队中多位成员都定义了名为foo的函数,应该怎么解决命名冲突?Python 中每个文件就代表了一个模块(module),在不同的模块中可以有同名的函数,在使用函数的时候就可以通过import关键字导入指定的模块再使用完全限定名(模块名.函数名)的调用方式,就能做出区分

复制代码
# module1.py
def foo():
    print('hello, world!')

# module2.py
def foo():
    print('goodbye, world!')

# test.py
import module1
import module2

# 用"模块名.函数名"的方式(完全限定名)调用函数
module1.foo()  # hello, world!
module2.foo()  # goodbye, world!

在导入模块时,还可以使用as关键字对模块进行别名,这样就可以使用更为简短的完全限定名

复制代码
import module1 as m1
import module2 as m2

m1.foo()  # hello, world!
m2.foo()  # goodbye, world!

Python中也提供from...import...语法,直接导入需要使用的函数

复制代码
# test.py
from module1 import foo
foo()  # hello, world!

from module2 import foo
foo()  # goodbye, world!

但是从两个不同的模块中导入了同名的函数,后面导入的函数会替换掉之前的导入的内容

复制代码
from module1 import foo
from module2 import foo

foo()  # goodbye, world!

如果想同时使用来自两个模块的foo函数,还是用as关键字对导入的函数进行别名

复制代码
from module1 import foo as f1
from module2 import foo as f2

f1()  # hello, world!
f2()  # goodbye, world!

函数应用实战

例子1:判断素数

复制代码
# 设计一个判断给定的大于1的正整数是不是质数的函数
# 质数是只能被1和自身整除的正整数(大于1),如果一个大于1的正整数N是质数,那就意味着在2到N−1之间都没有它的因子

def is_prime(num: int) -> bool:

    for i in range(2, int(num ** 0.5) + 1):
        if num % i == 0:
            return False
    return True

说明1:
# is_prime函数的参数num后面的: int用来标注参数的类型
# 虽然对代码的执行结果不产生任何影响,但是很好的增强了代码的可读性
# 同理,参数列表后面的-> bool用来标注函数返回值的类型,调用函数会得到一个布尔值,要么是True,要么是False

说明2:
# 因为num如果是合数的话,是肯定是a*b=num,肯定是一个小数(a),一个大数(b),如果到了num ** 0.5还没有的话,后面就不会有合数的因子了

例子2:最大公约数和最小公倍数

x 和 y 的最大公约数是能够同时整除 x 和 y 的最大整数,如果 x 和 y 互质,那么它们的最大公约数为 1; x 和 y 的最小公倍数是能够同时被 x 和 y 整除的最小正整数,如果 x 和 y 互质,那么它们的最小公倍数为 x×y

复制代码
def lcm(x: int, y: int) -> int:
    """求最小公倍数"""
    return x * y // gcd(x, y)


def gcd(x: int, y: int) -> int:
    """求最大公约数"""
    while y % x != 0:
        x, y = y % x, x
    return x

14、第十四天

能不能用函数作为函数的参数,用函数作为函数的返回值?先说结论,Python 中的函数是"一等函数",所谓"一等函数"指的就是函数可以赋值给变量,函数可以作为函数的参数,函数也可以作为函数的返回值。把一个函数作为其他函数的参数或返回值的用法,通常称之为"高阶函数"

高阶函数

复制代码
# 传入任意多个参数,对其中int类型或float类型的元素实现求和操作
def calc(*args, **kwargs):
    items = list(args) + list(kwargs.values())
    result = 0
    for item in items:
        if type(item) in (int, float):
            result += item
    return result

如果将上面的代码求和进行解耦(因为函数中使用了 += 运算符,这使得函数跟加法运算形成了耦合关系),让函数的通用性和灵活性就会更好的话,可以将+ 运算符变成函数调用,并将其设计为函数的参数

复制代码
def calc(init_value, op_func, *args, **kwargs):
    items = list(args) + list(kwargs.values())
    result = init_value
    for item in items:
        if type(item) in (int, float):
            result = op_func(result, item)
    return result

# 函数新增两个参数,其中init_value代表运算的初始值,op_func代表二元运算函数

def add(x, y):
    return x + y        #做求和的运算
print(calc(0, add, 1, 2, 3, 4, 5))  # 15

def mul(x, y):
    return x * y        #做求乘积运算
print(calc(1, mul, 1, 2, 3, 4, 5))  # 120 

#上面的calc函数通过将运算符变成函数的参数,实现了跟加法运算的解耦合
#调用函数需要在函数名后面跟上圆括号,而把函数作为参数时只需要函数名即可

如果没有提前定义好add和mul函数,也可以使用 Python 标准库中的operator模块提供的add和mul函数,它们分别代表了做加法和做乘法的二元运算

复制代码
import operator

print(calc(0, operator.add, 1, 2, 3, 4, 5))  # 15
print(calc(1, operator.mul, 1, 2, 3, 4, 5))  # 120

再去讨论一个内置函数sorted,可以对容器型数据类型(如:列表、字典等)元素的排序,sorted函数从功能上来讲跟列表的sort方法没有区别,但它会返回排序后的列表对象,而不是直接修改原来的列表,这点被称为函数的无副作用设计,也就是说调用函数除了产生返回值以外,不会对程序的状态或外部环境产生任何其他的影响

复制代码
old_strings = ['in', 'apple', 'zoo', 'waxberry', 'pear']
new_strings = sorted(old_strings)
print(new_strings)  # ['apple', 'in', 'pear', waxberry', 'zoo']

# 如果希望根据字符串的长度而不是字母表顺序对列表元素排序,可以向sorted函数传入一个名为key的参数,将key参数赋值为获取字符串长度的函数len

old_strings = ['in', 'apple', 'zoo', 'waxberry', 'pear']
new_strings = sorted(old_strings, key=len)
print(new_strings)  # ['in', 'zoo', 'pear', 'apple', 'waxberry']

Lambda函数(匿名函数)

如果作为参数或者返回值的函数本身非常简单,一行代码就能够完成,也不需要考虑对函数的复用,那么就可以使用 lambda 函数。Python 中的 lambda 函数是没有的名字函数,所以也叫做匿名函数,lambda 函数只能有一行代码,代码中的表达式产生的运算结果就是这个匿名函数的返回值

复制代码
old_nums = [35, 12, 8, 99, 60, 52]
new_nums = list(map(lambda x: x ** 2, filter(lambda x: x % 2 == 0, old_nums)))
print(new_nums)  # [144, 64, 3600, 2704]

通过上面的代码可以看出,定义 lambda 函数的关键字是lambda,后面跟函数的参数,如果有多个参数用逗号进行分隔;冒号后面的部分就是函数的执行体,通常是一个表达式,表达式的运算结果就是 lambda 函数的返回值,不需要写return 关键字

复制代码
# 函数是可以直接赋值给变量的
import functools
import operator

# 用一行代码实现计算阶乘的函数
fac = lambda n: functools.reduce(operator.mul, range(2, n + 1), 1)

# 用一行代码实现判断素数的函数
is_prime = lambda x: all(map(lambda f: x % f, range(2, int(x ** 0.5) + 1)))

# 调用Lambda函数
print(fac(6))        # 720
print(is_prime(37))  # True

reduce是Python中的functools的函数,主要是归纳操作,类似于calc函数,第一个参数是代表运算的函数,第二个参数是运算的数据,第三个参数是运算的初始值。与filter,map函数一起构成了处理数据中非常关键的三个动作:过滤、映射和归约

偏函数

偏函数:指固定函数的某些参数,生成一个新的函数,好处是无需在每次调用函数时都传递相同的参数

复制代码
# 例如,int函数在默认情况下可以将字符串视为十进制整数进行类型转换,
# 如果我们修改它的base参数,就可以定义出三个新函数,分别用于将二进制、八进制、十六进制字符串转换为整数

import functools

int2 = functools.partial(int, base=2)
int8 = functools.partial(int, base=8)
int16 = functools.partial(int, base=16)

print(int('1001'))    # 1001

print(int2('1001'))   # 9
print(int8('1001'))   # 513
print(int16('1001'))  # 4097

# partial函数的第一个参数和返回值都是函数,它将传入的函数处理成一个新的函数返回

15、第十五天

装饰器(高阶函数)

Python 语言中,装饰器是 "用一个函数装饰另外一个函数并为其提供额外的能力" 的语法现象。装饰器本身是一个函数,它的参数是被装饰的函数,它的返回值是一个带有装饰功能的函数

复制代码
# 假设有名为downlaod和upload的两个函数,分别用于文件的下载和上传

import random
import time

def download(filename): # 下载文件
    print(f'开始下载{filename}.')
    time.sleep(random.random() * 6)
    print(f'{filename}下载完成.')

def upload(filename): # 上传文件
    print(f'开始上传{filename}.')
    time.sleep(random.random() * 8)
    print(f'{filename}上传完成.')

download('MySQL从删库到跑路.avi')
upload('Python从入门到住院.pdf')

# 代码用休眠一段随机时间的方式模拟了下载和上传文件需要花费一定的时间

如果希望知道调用download和upload函数上传下载文件到底用了多少时间,可以在函数开始执行的时候记录一个时间,在函数调用结束后记录一个时间,两个时间相减就可以计算出下载或上传的时间

复制代码
start = time.time()
download('MySQL从删库到跑路.avi')
end = time.time()
print(f'花费时间: {end - start:.2f}秒')

start = time.time()
upload('Python从入门到住院.pdf')
end = time.time()
print(f'花费时间: {end - start:.2f}秒')

通过上面的代码,可以在下载和上传文件时记录下耗费的时间,不过记录时间、计算和显示执行时间的代码都是重复代码,有没有其他的办法?在 Python 语言中,装饰器就是解决这类问题的最佳选择。通过装饰器语法,可以把跟原来的业务(上传和下载)没有关系计时功能的代码封装到一个函数中,如果upload和download函数需要记录时间,可以直接把装饰器作用到这两个函数上即可

复制代码
# 装饰器是一个高阶函数,它的参数和返回值都是函数,将记录时间的装饰器命名为record_time
def record_time(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result
    return wrapper

record_time函数的参数func代表了一个被装饰的函数,函数里面定义的wrapper函数是带有装饰功能的函数,它会执行被装饰的函数func,它还需要返回在最后产生函数执行的返回值。record_time函数最终会返回这个带有装饰功能的函数wrapper并通过它替代原函数func,当原函数func被record_time函数装饰后, 后续调用时其实调用的是wrapper函数所以才获得了额外的能力。wrapper函数的参数比较特殊,由于要用wrapper替代原函数func,但是又不清楚原函数func会接受哪些参数,所以就通过可变参数和关键字参数照单全收,然后在调用func的时候,原封不动的全部给它。Python 语言支持函数的嵌套定义,但这个操作在很多编程语言中并不被支持

复制代码
import time

# 将记录时间的功能写到这个装饰器
def record_time(func):

    def wrapper(*args, **kwargs):
        start = time.time()    # 在执行被装饰的函数之前记录开始时间
        result = func(*args, **kwargs)    # 执行被装饰的函数并获取返回值
        end = time.time()    # 在执行被装饰的函数之后记录结束时间
        print(f'{func.__name__}执行时间: {end - start:.2f}秒')    # 计算和显示被装饰函数的执行时间
        return result    # 返回被装饰函数的返回值
    
    return wrapper

装饰器虽然颇费周折,但是是一个一劳永逸的操作,后面再有记录函数执行时间的需求时,直接添加上装饰器就好。如果使用装饰器的话,可以直接调用装饰器函数,传入被装饰的函数并获得返回值,可以用这个返回值直接替代原来的函数

复制代码
download = record_time(download)
upload = record_time(upload)
download('MySQL从删库到跑路.avi')
upload('Python从入门到住院.pdf')

在 Python 中,使用装饰器还有更为便捷的 语法糖,可以用@装饰器函数将装饰器函数直接放在被装饰的函数上,效果跟上面的代码相同(编程语言中添加的某种语法,这种语法对语言的功能没有影响,但是使用更加便捷,代码的可读性也更强,将其称之为"语法糖"或"糖衣语法")

如果在代码的某些地方,想要去掉装饰器的作用执行原函数,Python 标准库functools模块的wraps函数也是一个装饰器,将它放在wrapper函数上,可以保留被装饰之前的函数,在需要取消装饰器时,可以通过被装饰函数的 wrapped 属性获得被装饰之前的函数

复制代码
import random
import time
from functools import wraps

def record_time(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f'{func.__name__}执行时间: {end - start:.2f}秒')
        return result
    return wrapper

@record_time
def download(filename):
    print(f'开始下载{filename}.')
    time.sleep(random.random() * 6)
    print(f'{filename}下载完成.')

@record_time
def upload(filename):
    print(f'开始上传{filename}.')
    time.sleep(random.random() * 8)
    print(f'{filename}上传完成.')

# 调用装饰后的函数会记录执行时间
download('MySQL从删库到跑路.avi')
upload('Python从入门到住院.pdf')

# 取消装饰器的作用不记录执行时间
download.__wrapped__('MySQL必知必会.pdf')
upload.__wrapped__('Python从新手到大师.pdf')

装饰器函数本身也可以参数化,就是装饰器也是可以通过调用者传入的参数来进行定制的

递归调用

Python 函数可以直接或间接地调用自身,然后将函数自己调用自己称为递归调用。现实中,有很多问题的定义本身就是一个递归定义,例如之前说到的阶乘,非负整数N的阶乘是N乘以N-1的阶乘,即 N!=N×(N−1)! 定义的左边和右边都出现了阶乘的概念,所以这是一个递归定义

复制代码
def fac(num):
    if num in (0, 1):
        return 1
    return num * fac(num - 1)

fac函数中又调用了fac函数,就是所谓的递归调用,if 条件叫做递归的收敛条件,说明什么时候要结束函数的递归调用,在计算阶乘时,如果计算到0或1的阶乘,就停止递归调用,直接返回1;num * fac(num - 1)是递归公式,也就是阶乘的递归定义

函数调用会通过内存中称为"栈"(stack)的数据结构来保存当前代码的执行现场,函数调用结束后会通过栈结构恢复之前的执行现场。栈是一种先进后出的数据结构,这也就意味着最早入栈的函数最后才会返回,而最后入栈的函数会最先返回。例如调用一个名为a的函数,函数a的执行体中又调用了函数b,函数b的执行体中又调用了函数c,那么最先入栈的函数是a,最先出栈的函数是c。每进入一个函数调用,栈就会增加一层栈帧(stack frame),就是保存当前代码执行现场的结构;每当函数调用结束后,栈就会减少一层栈帧。通常,内存中的栈空间很小,因此递归调用的次数如果太多,会导致栈溢出(stack overflow),所以递归调用一定要确保能够快速收敛。可以尝试执行fac(5000),会提示RecursionError错误,错误消息为:maximum recursion depth exceeded in comparison(超出最大递归深度),其实就是发生了栈溢出

官方的 Python 解释器(CPython),会默认将函数调用的栈结构最大深度设置为1000层,如果超出这个深度,就会发生上面说的RecursionError。当然,可以使用sys模块的 setrecursionlimit 函数来改变递归调用的最大深度,不过不建议这么做,因为需要让递归快速收敛,否则就应该考虑使用循环递推而不是递归

复制代码
# 递归调用函数来计算第​n个斐波那契数
def fib1(n):
    if n in (1, 2):
        return 1
    return fib1(n - 1) + fib1(n - 2)

for i in range(1, 21):
    print(fib1(i))

# 代码虽然看起来非常简单明了,但执行性能是比较糟糕的

# 很显然,直接使用循环递推的方式获得斐波那契数列是更好的选择
def fib2(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

除此以外,还可以使用 Python 标准库中functools模块的lru_cache函数来优化上面的递归代码。lru_cache函数是一个装饰器函数,将其置于上面的函数fib1之上,就可以缓存该函数的执行结果从而避免在递归调用的过程中产生大量的重复运算,代码的执行性就会有很大的提升

复制代码
from functools import lru_cache
# lru_cache函数是一个带参数的装饰器,lru_cache后面要跟上圆括号
# lru_cache函数有一个非常重要的参数叫maxsize,它可以用来定义缓存空间的大小,默认值是128

@lru_cache()
def fib1(n):
    if n in (1, 2):
        return 1
    return fib1(n - 1) + fib1(n - 2)

for i in range(1, 51):
    print(i, fib1(i))

总结:装饰器是 Python 语言中的特色语法,可以通过装饰器来增强现有的函数。另一方面,通过函数递归调用,可以在代码层面将一些复杂的问题简单化,但是递归调用一定要注意收敛条件和递归公式,找到递归公式才有机会使用递归调用,而收敛条件则确保了递归调用能停下来。函数调用通过内存中的栈空间来保存现场和恢复现场,栈空间通常都很小,所以递归如果不能迅速收敛,很可能会引发栈溢出错误,从而导致程序的崩溃

16、第十六天

面向对象编程是一种非常流行的编程范式,就是对程序的认知和理解以及编写代码的方式。在面向对象编程的世界里,程序中的数据和操作数据的函数是一个逻辑上的整体,称之为对象。对象可以接收消息,解决问题的方法就是创建对象并向对象发出各种各样的消息;通过消息传递,程序中的多个对象可以协同工作,这样就能构造出复杂的系统并解决现实中的问题

类和对象

面向对象编程:把一组数据和处理数据的方法组成对象,把行为相同的对象归纳为类,通过封装隐藏对象的内部细节,通过继承实现类的特化和泛化,通过多态实现基于对象类型的动态分派

关键词:对象、类、封装、继承、多态

在面向对象编程的世界中,一切皆为对象。每个对象都是独一无二的,且必定属于某个类。作为一个具体的、可接受消息的实体,对象都拥有静态特征(属性)和动态特征(行为)。如果将拥有共同属性和行为的对象的特征抽取出来,就形成了一个抽象的概念------类。简而言之,类是对象的蓝图和模板,而对象则是类的具体实例。比如,"人类"就是一个抽象的类,而每一个具体的人,就是这个概念下实实在在存在的对象

定义类

在 Python 语言中,可以使用 class 关键字加上类名来定义类,通过缩进可以确定类的代块,如同定义函数一样。在类的代码块中,需要写一些函数,因为类是一个抽象概念,那么这些函数就是对一类对象 共同的动态特征的提取。写在类里面的函数通常称之为方法,方法就是对象的行为,也就是对象可以接收的消息。方法的第一个参数通常都是self,它代表了接收这个消息的对象本身

复制代码
class Student:
    def study(self, course_name):
        print(f'学生正在学习{course_name}.')

    def play(self):
        print(f'学生正在玩游戏.')

创建和使用对象

复制代码
# 在定义好一个类之后,可以使用构造器语法来创建对象

stu1 = Student()
stu2 = Student()
print(stu1)    # <__main__.Student object at 0x10ad5ac50>
print(stu2)    # <__main__.Student object at 0x10ad5acd0> 
print(hex(id(stu1)), hex(id(stu2)))    # 0x10ad5ac50 0x10ad5acd0

在类的名字后跟上圆括号就是所谓的构造器语法,代码构建了两个学生对象,一个赋值给变量stu1,一个赋值给变量stu2。当用print函数打印stu1和stu2两个变量时,会看到输出的对象在内存中的地址(十六进制形式)。其中,定义的变量其实保存的是一个对象在内存中的逻辑地址(位置),通过这个逻辑地址,可以在内存中找到这个对象。如果是stu3 = stu2这样的赋值语句的话,并没有创建新的对象,只是用一个新的变量保存了已有对象的地址

复制代码
# 通过"类.方法"调用方法,第一个参数是接收消息的对象,第二个参数是学习的课程名称
Student.study(stu1, 'Python程序设计')    # 学生正在学习Python程序设计

# 通过"对象.方法"调用方法,点前面的对象就是接收消息的对象,只需要传入第二个参数课程名称

stu1.study('Python程序设计')             # 学生正在学习Python程序设计

Student.play(stu2)                      # 学生正在玩游戏
stu2.play()                             # 学生正在玩游戏

初始化方法

如果想给学生对象定义属性,可以修改Student类,为其添加一个名为__init__的方法。在调用Student类的构造器创建对象时,首先会在内存中获得保存学生对象所需的内存空间,然后通过自动执行__init__方法,完成对内存的初始化操作,也就是把数据放到内存空间中。通过给Student类添加__init__方法的方式为学生对象指定属性,同时完成对属性赋初始值的操作

复制代码
# 给学生对象添加name(姓名)和age(年龄)两个属性

class Student:
    """学生"""

    def __init__(self, name, age):
        """初始化方法"""
        self.name = name
        self.age = age

    def study(self, course_name):
        """学习"""
        print(f'{self.name}正在学习{course_name}.')

    def play(self):
        """玩耍"""
        print(f'{self.name}正在玩游戏.')

# 调用Student类的构造器创建对象并传入初始化参数

stu1 = Student('小红', 44)
stu2 = Student('小黄', 25)
stu1.study('Python程序设计')    # 小红正在学习Python程序设计.
stu2.play()                    # 小黄正在玩游戏.

面向对象的支柱

面向对象编程有三大支柱:封装、继承和多态。首先介绍一下对封装的理解,封装就是隐藏一切可以隐藏的实现细节,只向外界暴露简单的调用接口。在类中定义的对象方法其实就是一种封装,这种封装可以在创建对象之后,只需要给对象发送一个消息就可以执行方法中的代码,也就是说只知道方法的名字和参数(方法的外部视图),不知道方法内部实现细节(方法的内部视图)的情况下就完成了对方法的使用

在面对一些场景下,面向对象编程就是一个三步走的问题。第一步定义类,第二步创建对象,第三步给对象发消息。有的时候想要使用的类可能已经存在了,比如Python内置的list、set、dict 其实都是类,如果需要创建列表、集合、字典对象,就不需要定义类了。在默写特殊场景下,会用到名为"内置对象"的对象:类已经存在而且对象已然创建过了,直接向对象发消息就可以了

面向对象案例

例子1:时钟

复制代码
# 要求:定义一个类描述数字时钟,提供走字和显示时间的功能

import time

# 定义时钟类
class Clock:
    """数字时钟"""

    def __init__(self, hour=0, minute=0, second=0):
        self.hour = hour
        self.min = minute
        self.sec = second

    def run(self):
        self.sec += 1
        if self.sec == 60:
            self.sec = 0
            self.min += 1
            if self.min == 60:
                self.min = 0
                self.hour += 1
                if self.hour == 24:
                    self.hour = 0

    def show(self):
        """显示时间"""
        return f'{self.hour:0>2d}:{self.min:0>2d}:{self.sec:0>2d}'

# 创建时钟对象
clock = Clock(23, 59, 58)
while True:
    print(clock.show())
    # 休眠1秒钟
    time.sleep(1)
    clock.run()

例子2:平面上的点

复制代码
# 要求:定义一个类描述平面上的点,提供计算到另一个点距离的方法

class Point:
    """平面上的点"""

    def __init__(self, x=0, y=0):
        self.x, self.y = x, y

    def distance_to(self, other):
        dx = self.x - other.x
        dy = self.y - other.y
        return (dx * dx + dy * dy) ** 0.5

    def __str__(self):
        return f'({self.x}, {self.y})'

p1 = Point(3, 5)
p2 = Point(6, 9)
print(p1)  
print(p2)
print(p1.distance_to(p2))

17、第十七天

可见性和属性装饰器

在很多面向对象编程语言中,对象的属性通常会被设置为私有(private)或受保护(protected)的成员,简单的说就是不允许直接访问这些属性;对象的方法通常都是公开的(public),因为公开的方法是对象能够接受的消息,也是对象暴露给外界的调用接口,这就是所谓的访问可见性。在 Python 中,可以通过给对象属性名添加前缀下划线的方式来说明属性的访问可见性,例如,可以用__name表示一个私有属性,_name表示一个受保护属性

复制代码
class Student:
    def __init__(self, name, age):
        self.__name = name
        self.__age = age

    def study(self, course_name):
        print(f'{self.__name}正在学习{course_name}.')

stu = Student('小红', 20)
stu.study('Python程序设计')
print(stu.__name)  # AttributeError: 'Student' object has no attribute '__name'

AttributeError(属性错误)异常,异常消息为:'Student' object has no attribute '__name'。由此可见,以 __开头的属性 __name相当于是私有的,在类的外面无法直接访问,但是类里面的study方法中可以通过self.__name访问该属性。不过其实 Python 语言并没有从语义上做出最严格的限定,如果愿意的话,依旧可以用stu._Student__name的方式,访问到私有属性__name

动态属性

Python 语言属于动态语言。动态语言:在运行时可以改变其结构的语言,例如新的函数、对象、甚至代码可以被引进,已有的函数可以被删除或是其他结构上的变化。在Python中,可以动态为对象添加属性,属于Python 作为动态类型语言的一项特权。不过提醒的是,对象的方法其实本质上也是对象的属性,如果给对象发送一个无法接收的消息,引发的异常仍然是AttributeError

复制代码
class Student:
    def __init__(self, name, age):
        self.name = name
        self.age = age

stu = Student('小黄', 20)
stu.sex = '男'  # 给学生对象动态添加sex属性

如果想防止在使用对象时动态的为对象添加属性,可以使用 Python 语言中的__slots__函数。对于Student类来说,可以在类中指定__slots__ = ('name', 'age'),这样Student类的对象只能有name和age属性,如果想动态添加其他属性将会引发异常

复制代码
class Student:
    __slots__ = ('name', 'age')

    def __init__(self, name, age):
        self.name = name
        self.age = age

stu = Student('小黄', 20)
# AttributeError: 'Student' object has no attribute 'sex'
stu.sex = '男'

静态方法和类方法

除了对象方法之外,类中还可以有静态方法和类方法,这两类方法是发给类的消息,二者并没有实质性的区别。在面向对象的世界里,定义的每一个类其实也是一个对象,而静态方法和类方法就是发送给类对象的消息

举个例子:定义一个三角形类,通过传入三条边的长度来构造三角形。计算周长和面积肯定是三角形对象的方法,但是在创建三角形对象时,传入的三条边长未必能构造出三角形,为此可以先写一个方法来验证给定的三条边长是否可以构成三角形,这种方法很显然就不是对象方法,因为在调用这个方法时三角形对象还没有创建出来。所以可以把这类方法设计为静态方法或类方法,也就是说这类方法不是发送给三角形对象的消息,而是发送给三角形类的消息

复制代码
class Triangle(object):
    """三角形"""

    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c

    @staticmethod
    def is_valid(a, b, c):
        """判断三条边长能否构成三角形(静态方法)"""
        return a + b > c and b + c > a and a + c > b

    # @classmethod
    # def is_valid(cls, a, b, c):
    #     """判断三条边长能否构成三角形(类方法)"""
    #     return a + b > c and b + c > a and a + c > b

    def perimeter(self):
        """计算周长"""
        return self.a + self.b + self.c

    def area(self):
        """计算面积"""
        p = self.perimeter() / 2
        return (p * (p - self.a) * (p - self.b) * (p - self.c)) ** 0.5
      

if Triangle.is_valid(3, 4, 5):
    t = Triangle(3, 4, 5)
    print(f'周长: {t.perimeter()}')
    print(f'面积: {t.area()}')
else:
    print('无效的边长!!!')

代码中,staticmethod 装饰器声明了is_valid方法是Triangle类的静态方法,如果要声明类方法,可以使用classmethod装饰器。可以直接使用类名**.**方法名的方式来调用静态方法和类方法,二者的区别在于,类方法的第一个参数是类对象本身,而静态方法则没有这个参数

简单的总结一下,对象方法、类方法、静态方法都可以通过"类名.方法名"的方式来调用,区别在于方法的第一个参数到底是普通对象还是类对象,还是没有接受消息的对象。静态方法通常也可以直接写成一个独立的函数,因为它并没有跟特定的对象绑定

复制代码
# 可以在计算三角形周长和面积的方法添加一个property装饰器(Python 内置类型),
# 这样perimeter和area就变成了两个属性,不再通过调用方法的方式来访问,而是用对象访问属性的方式直接获得

class Triangle(object):
    """三角形"""

    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c

    @staticmethod
    def is_valid(a, b, c):
        return a + b > c and b + c > a and a + c > b

    @property
    def perimeter(self):
        return self.a + self.b + self.c

    @property
    def area(self):
        p = self.perimeter / 2
        return (p * (p - self.a) * (p - self.b) * (p - self.c)) ** 0.5

if Triangle.is_valid(3, 4, 5):
    t = Triangle(3, 4, 5)
    print(f'周长: {t.perimeter}')
    print(f'面积: {t.area}')
else:
    print('无效的边长!!!')

继承和多态

面向对象的编程语言支持在已有类的基础上创建新类,从而减少重复代码的编写。提供继承信息的类叫做父类(超类、基类),得到继承信息的类叫做子类(派生类、衍生类)。例如:定义一个学生类和一个老师类,会发现他们有大量的重复代码,而这些重复代码都是老师和学生作为人的公共属性和行为,所以在这种情况下,应该先定义人类,再通过继承,从人类派生出老师类和学生类

复制代码
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    def eat(self):
        print(f'{self.name}正在吃饭.')
    
    def sleep(self):
        print(f'{self.name}正在睡觉.')

class Student(Person):
    def __init__(self, name, age):
        super().__init__(name, age)
    
    def study(self, course_name):
        print(f'{self.name}正在学习{course_name}.')

class Teacher(Person):
    def __init__(self, name, age, title):
        super().__init__(name, age)
        self.title = title
    
    def teach(self, course_name):
        print(f'{self.name}{self.title}正在讲授{course_name}.')

stu1 = Student('小白', 21)
tea1 = Teacher('小黑', 35, '副教授')
stu1.eat()
stu1.sleep()
tea1.eat()
stu1.study('Python程序设计')
tea1.teach('Python程序设计')

继承 :在定义类的时候,在类名后的圆括号中指定当前类的父类。如果定义一个类的时候没有指定它的父类是谁,那么默认的父类是object 类。object类是 Python 中的顶级类,这也就意味着所有的类都是它的子类,要么直接继承它,要么间接继承它。Python 语言允许多重继承,也就是说一个类可以有一个或多个父类,在子类的初始化方法中,可以通过**super().init()**来调用父类初始化方法。super函数是 Python 内置函数中专门为获取当前对象的父类对象而设计的,子类除了可以通过继承得到父类提供的属性和方法外,还可以定义自己特有的属性和方法,所以子类比父类拥有的更多的能力。在实际开发中,经常会用子类对象去替换掉一个父类对象,也被叫做 "里氏替换原则"(Liskov Substitution Principle)

子类继承父类的方法后,还可以对方法进行重写(重新实现该方法),不同的子类可以对父类的同一个方法给出不同的实现版本,这样的方法在程序运行时就会表现出多态行为(调用相同的方法,做了不同的事情)

总结:Python 是动态类型语言,Python 中的对象可以动态的添加属性,对象的方法其实也是属性,只不过和该属性对应的是一个可以调用的函数。在面向对象的世界中,一切皆为对象,定义的类也是对象,所以类也可以接收消息,对应的方法是类方法或静态方法。通过继承,可以从已有的类创建新类,实现对已有类代码的复用

18、第十八天

持久化:将数据从无法长久保存数据的存储介质(通常是内存)转移到可以长久保存数据的存储介质(通常是硬盘)中。实现数据持久化最直接简单的方式就是通过文件系统将 数据保存到文件中

计算机的文件系统是一种存储和组织计算机数据的方法,它使得对数据的访问和查找变得容易,文件系统使用文件和树形目录的抽象逻辑概念代替了硬盘、光盘、闪存等物理设备的数据块概念,用户使用文件系统来保存数据时,不必关心数据实际保存在硬盘的哪个数据块上,只需要记住这个文件的路径和文件名。在写入新数据之前,用户不必关心硬盘上的哪个数据块没有被使用,硬盘上的存储空间管理功能由文件系统自动完成,用户只需要记住数据被写入到了哪个文件中

打开和关闭文件

在 Python 中要实现文件操作是非常简单的,可以使用open函数,通过函数的参数指定文件名、操作模式和字符编码等信息,就可以对文件进行读写操作。所说的操作模式是指要打开什么样的文件(字符文件或二进制文件)以及做什么样的操作(读、写或追加)

在使用open函数时,如果打开的文件是字符文件(文本文件),可以通过encoding 参数来指定读写文件使用的字符编码。使用open函数打开文件成功后会返回一个文件对象,通过这个对象,就可以实现对文件的读写操作;如果打开文件失败,open函数会引发异常。如果要关闭打开的文件,可以使用文件对象的close方法,这样可以在结束文件操作时释放掉这个文件

读写文本文件

用open函数打开文本文件时,需要指定文件名并将文件的操作模式设置为'r',如果不指定,默认值也是'r';如果需要指定字符编码,可以传入encoding参数,如果不指定,默认值是None,那么在读取文件时使用的是操作系统默认的编码。如果不能保证保存文件时使用的编码方式与encoding参数指定的编码方式是一致的,那么就可能因无法解码字符而导致读取文件失

复制代码
file = open('致橡树.txt', 'r', encoding='utf-8')
print(file.read())
file.close()

除了使用文件对象的read方法读取文件之外,还可以使用 for-in 循环逐行读取或者用 readlines 方法将文件按行读取到一个列表容器中

复制代码
file = open('致橡树.txt', 'r', encoding='utf-8')
for line in file:
    print(line, end='')
file.close()

file = open('致橡树.txt', 'r', encoding='utf-8')
lines = file.readlines()
for line in lines:
    print(line, end='')
file.close()

如果要向文件中写入内容,可以在打开文件时使用 w 或者 a 作为操作模式,前者会截断之前的文本内容写入新的内容,后者是在原来内容的尾部追加新的内容

复制代码
file = open('致橡树.txt', 'a', encoding='utf-8')
file.write('\n标题:《致橡树》')
file.close()

异常处理机制

如果open函数指定的文件并不存在或者无法打开,那么将引发异常状况导致程序崩溃。为了让代码具有健壮性和容错性,可以使用 Python 的异常机制对可能在运行时发生状况的代码进行适当的处理。Python 中和异常相关的关键字有五个,分别是try、except、else、finally和raise

复制代码
file = None
try:
    file = open('致橡树.txt', 'r', encoding='utf-8')
    print(file.read())
except FileNotFoundError:
    print('无法打开指定的文件!')
except LookupError:
    print('指定了未知的编码!')
except UnicodeDecodeError:
    print('读取文件时解码错误!')
finally:
    if file:
        file.close()

在 Python 中,会将运行时会出现状况的代码放在try代码块中,在try后面可以跟上一个或多个except 块来捕获异常并进行相应的处理。在上面的代码中,文件找不到会引发FileNotFoundError,指定了未知的编码会引发LookupError,而如果读取文件时无法按指定编码方式解码文件会引发UnicodeDecodeError,所以会在try的后面跟上三个except去按顺序处理不同的异常情况。在except后面,还可以加上else代码块,try中的代码是在没有出现异常时才会执行的代码,而且else中的代码不会再进行异常捕获,也就是说如果遇到异常状况,程序会因异常而终止并报告异常信息。最后使用finally代码块来关闭打开的文件,释放掉程序中获取的外部资源,因为finally块的代码不论程序正常还是异常都会执行,甚至是调用了sys模块的exit函数终止 Python 程序。finally块中的代码仍然会被执行(因为exit函数的本质是引发了SystemExit异常),因此把finally代码块称为"总是执行代码块",最适合用来做释放外部资源的操作

如果异常已经处理就不会再进入后续的except语句,except语句中还可以通过元组同时指定多个异常类型进行捕获,except语句后面如果不指定异常类型,则默认捕获所有异常

复制代码
BaseException
 +-- SystemExit
 +-- KeyboardInterrupt
 +-- GeneratorExit
 +-- Exception
      +-- StopIteration
      +-- StopAsyncIteration
      +-- ArithmeticError
      +-- AssertionError
      +-- AttributeError
      +-- BufferError
      +-- EOFError
      +-- ImportError
      +-- LookupError
      +-- MemoryError
      +-- NameError
      +-- OSError
      +-- ReferenceError
      +-- SyntaxError
      +-- SystemError
      +-- TypeError
      +-- ValueError
      +-- Warning
           +-- DeprecationWarning
           +-- PendingDeprecationWarning
           +-- RuntimeWarning
           +-- SyntaxWarning
           +-- UserWarning
           +-- FutureWarning
           +-- ImportWarning
           +-- UnicodeWarning
           +-- BytesWarning
           +-- ResourceWarning    

从继承结构可以看出,Python 中所有的异常都是BaseException的子类型,它有四个直接的子类,分别是:SystemExit、KeyboardInterrupt、GeneratorExit和Exception

  • SystemExit:表示解释器请求退出
  • KeyboardInterrupt:表示用户中断程序执行(按下Ctrl+c)
  • GeneratorExit:表示生成器发生异常通知退出

而Exception类,它是常规异常类型的父类型,很多的异常都是直接或间接的继承自Exception类。如果 Python 内置的异常类型不能满足应用程序的需要,可以自定义异常类型,而自定义的异常类型也会直接或间接继承自Exception类

在 Python 中,可以使用 raise 关键字来引发异常(抛出异常对象),而调用者可以通过try...except...结构来捕获并处理异常。例如在函数中,当函数的执行条件不满足时,可以使用抛出异常的方式来告知其问题的所在,而调用者可以通过捕获处理异常来使得代码从异常中恢复

复制代码
class InputError(ValueError):
    """自定义异常类型"""
    pass

def fac(num):
    if num < 0:
        raise InputError('只能计算非负整数的阶乘')
    if num in (0, 1):
        return 1
    return num * fac(num - 1)

# 调用求阶乘的函数fac,通过try...except...结构捕获输入错误的异常并打印异常对象
# 如果输入正确就计算阶乘并结束程序

flag = True
while flag:
    num = int(input('n = '))
    try:
        print(f'{num}! = {fac(num)}')
        flag = False
    except InputError as err:
        print(err)

捕获异常后可以使用raise要再次抛出,但是不建议捕获并抛出同一个异常;不建议在不清楚逻辑的情况下捕获所有异常,这可能会掩盖程序中严重的问题。不要使用异常机制来处理正常业务逻辑或控制程序流程,简单的说就是不要滥用异常机制

上下文管理器语法

对于open函数返回的文件对象,还可以使用with上下文管理器语法在文件操作完成后自动执行文件对象的close方法,会让代码变得更加简单优雅,不用去写finally代码块来执行关闭文件释放资源的操作。不过不是所有的对象都可以放在with上下文语法中,只有符合上下文管理器协议(有__enter__和__exit__魔术方法)的对象才能使用这种语法

复制代码
# 用with上下文语法改写后的代码

try:
    with open('致橡树.txt', 'r', encoding='utf-8') as file:
        print(file.read())
except FileNotFoundError:
    print('无法打开指定的文件!')
except LookupError:
    print('指定了未知的编码!')
except UnicodeDecodeError:
    print('读取文件时解码错误!')

读写二进制文件

读写二进制文件跟读写文本文件的操作类似,不过在使用open函数打开文件时,如果要进行读操作,操作模式是'rb',如果要进行写操作,操作模式是'wb'。读写文本文件时,read方法的返回值以及write方法的参数是str对象(字符串),而读写二进制文件时,read方法的返回值以及write方法的参数是bytes-like对象(字节串)

复制代码
# 将当前路径下名为 guido.jpg 的图片文件复制到 吉多.jpg文件中的操作

try:
    with open('guido.jpg', 'rb') as file1:
        data = file1.read()
    with open('吉多.jpg', 'wb') as file2:
        file2.write(data)
except FileNotFoundError:
    print('指定的文件无法打开.')
except IOError:
    print('读写文件时出现错误.')
print('程序执行结束.')

如果要复制的图片文件很大,一次将文件内容直接读入内存中可能会造成非常大的内存开销,为了减少对内存的占用,可以为read方法传入size参数来指定每次读取的字节数,通过循环读取和写入的方式来完成上面的操作

复制代码
try:
    with open('guido.jpg', 'rb') as file1, open('吉多.jpg', 'wb') as file2:
        data = file1.read(512)
        while data:
            file2.write(data)
            data = file1.read()
except FileNotFoundError:
    print('指定的文件无法打开.')
except IOError:
    print('读写文件时出现错误.')
print('程序执行结束.')

19、第十九天

在 Python 中,可以将程序中的数据以 JSON(JavaScript Object Notation) 格式进行保存。本来是 JavaScript 语言中创建对象的一种字面量语法,目前已经被广泛的应用于跨语言跨平台的数据交换,特点是结构紧凑而且是纯文本,任何操作系统和编程语言都能处理纯文本,也是实现跨语言跨平台数据交换的前提条件。 JSON 基本上已经取代了 XML(可扩展标记语言)作为异构系统间交换数据的事实标准

复制代码
# 它跟 Python 中的字典非常类似而且支持嵌套结构,就像是字典中的值可以是另一个字典
{
    name: "小红",
    age: 25,
    friends: ["小白", "小蓝"],
    cars: [
        {"brand": "BMW", "max_speed": 240},
        {"brand": "Benz", "max_speed": 280},
        {"brand": "Audi", "max_speed": 280}
    ]
}

表1:JavaScript 数据类型(值)对应的 Python 数据类型(值)

表2:Python 数据类型(值)对应的JavaScript数据类型(值)

读写 JSON 格式的数据

如果要将字典处理成 JSON 格式(以字符串形式存在),可以使用json模块的dumps函数

复制代码
import json

my_dict = {
    'name': "小红",
    'age': 25,
    'friends': ["小白", "小蓝"],
    'cars': [
        {"brand": "BMW", "max_speed": 240},
        {"brand": "Benz", "max_speed": 280},
        {"brand": "Audi", "max_speed": 280}
    ]
}
print(json.dumps(my_dict))

# 注意到:中文字符都是用 Unicode 编码显示的
{"name": "\u9a86\u660a", "age": 40, "friends": ["\u738b\u5927\u9524", "\u767d\u5143\u82b3"], "cars": [{"brand": "BMW", "max_speed": 240}, {"brand": "Audi", "max_speed": 280}, {"brand": "Benz", "max_speed": 280}]}

如果要将字典处理成 JSON 格式并写入文本文件,只需要将 dumps 函数换成 dump 函数并传入文件对象即可

复制代码
import json

my_dict = {
    'name': "小红",
    'age': 25,
    'friends': ["小白", "小蓝"],
    'cars': [
        {"brand": "BMW", "max_speed": 240},
        {"brand": "Benz", "max_speed": 280},
        {"brand": "Audi", "max_speed": 280}
    ]
}
with open('data.json', 'w') as file:
    json.dump(my_dict, file)
# 执行上面的代码,会创建data.json文件,文件的内容跟上面代码的输出是一样的

json模块有四个比较重要的函数,分别是:

  • dump - 将 Python 对象按照 JSON 格式序列化到文件中
  • dumps - 将 Python 对象处理成 JSON 格式的字符串
  • load - 将文件中的 JSON 数据反序列化成对象
  • loads - 将字符串的内容反序列化成 Python 对象

这里出现了两个概念,一个叫序列化(serialization),一个叫反序列化(deserialization)。序列化在计算机科学的数据处理中,是指将数据结构或对象状态转换为可以存储或传输的形式,这样在需要的时候能够恢复到原先的状态,而且通过序列化的数据重新获取字节时,可以利用这些字节来产生原始对象的副本(拷贝)。与这个过程相反的动作,即从一系列字节中提取数据结构的操作,就是反序列化

复制代码
# 读取上面创建的data.json文件,将 JSON 格式的数据还原成 Python 中的字典
import json

with open('data.json', 'r') as file:
    my_dict = json.load(file)
    print(type(my_dict))
    print(my_dict)

包管理工具 pip

Python 标准库中的 json模块在数据序列化和反序列化时性能并不是非常理想,为了解决这个问题,可以使用三方库ujson来替换 json。所谓三方库,是指非公司内部开发和使用的,也不是来自于官方标准库的 Python 模块,这些模块通常由其他公司、组织或个人开发

Pip 是 Python 的包管理工具,通过 pip 可以查找、安装、卸载、更新 Python 的三方库或工具,可以在终端中通过 pip --version 来确定是否已经拥有了 pip

复制代码
# 安装替代json模块的ujson

pip install ujson

但是国内访问这个网站的速度并不是十分理想,因此国内用户可以使用豆瓣网或清华大学提供的镜像来替代这个默认的下载源,

复制代码
# 默认情况下,pip 会访问https://pypi.org/simple/来获得三方库相关的数据
# 不过国内访问这个网站的速度并不是十分理想
# 因此可以使用豆瓣网或清华大学提供的镜像来替代默认的下载源

pip config set global.index-url https://pypi.doubanio.com/simple
pip install ujson
  • pip search:根据名字查找需要的三方库
  • pip list:查看已经安装过的三方库
  • pip install -U / pip install --upgrade:更新某个三方库
  • pip uninstall:删除某个三方库

使用网络API获取数据

如果想在自己的程序中显示天气、路况、航班等信息,不过这些信息自己没有能力提供,所以必须使用网络数据服务。目前绝大多数的网络数据服务(网络 API)都是基于 HTTP 或 HTTPS 提供 JSON 格式的数据,可以通过 Python 程序发送 HTTP 请求给指定的 URL(统一资源定位符),这个 URL 就是所谓的网络 API,如果请求成功,它会返回 HTTP 响应,而 HTTP 响应的消息体中就有需要的 JSON 格式的数据

复制代码
# 展示如何使用requests库访问网络 API 获取国内新闻并显示新闻标题和链接

# Python 通过 URL 接入网络,推荐使用requests三方库,简单且强大
# pip install requests

import requests

resp = requests.get('http://api.tianapi.com/guonei/?key=APIKey&num=10')
if resp.status_code == 200:
    data_model = resp.json()
    for news in data_model['newslist']:
        print(news['title'])
        print(news['url'])
        print('-' * 60)

20、第二十天

CSV(Comma Separated Values)全称逗号分隔值文件是一种简单、通用的文件格式,被广泛的应用于应用程序(数据库、电子表格等)数据的导入和导出以及异构系统之间的数据交换。因为 CSV 是纯文本文件,不管是什么操作系统和编程语言都是可以处理纯文本的,而且很多编程语言中都提供了对读写 CSV 文件的支持,因此 在数据处理和数据科学中被广泛应用

CSV 文件有以下特点:

  • 纯文本,使用某种字符集(如 ASCII、Unicode、GB2312等)
  • 由一条条的记录组成(典型的是每行一条记录)
  • 每条记录被分隔符(如逗号、分号、制表符等)分隔为字段(列)
  • 每条记录都有同样的字段序列

CSV 文件可以使用文本编辑器或类似于 Excel 电子表格这类工具打开和编辑,当使用 Excel 这类电子表格打开 CSV 文件时,甚至感觉不到 CSV 和 Excel 文件的区别。很多数据库系统都支持将数据导出到 CSV 文件中,当然也支持从 CSV 文件中读入数据保存到数据库中

将数据写入 CSV 文件

现有五个学生三门课程的考试成绩需要保存到一个 CSV 文件中,可以使用 Python 标准库中的csv模块达成这个目标。该模块的 writer 函数会返回一个csvwriter对象,通过该对象的writerow或writerows方法就可以将数据写入到 CSV 文件中

复制代码
import csv
import random

with open('scores.csv', 'w') as file:
    writer = csv.writer(file)
    writer.writerow(['姓名', '语文', '数学', '英语'])
    names = ['小羽', '小张', '小赵', '小马', '小黄']
    for name in names:
        scores = [random.randrange(50, 101) for _ in range(3)]
        scores.insert(0, name)
        writer.writerow(scores)

上面的writer函数,除了传入要写入数据的文件对象外,还可以dialect参数(默认值是excel)。除此之外,还可以通过delimiter、quotechar、quoting参数来指定分隔符(默认是逗号)、包围值的字符(默认是双引号)以及包围的方式。其中,包围值的字符主要用于当字段中有特殊符号时,通过添加包围值的字符可以避免二义性

复制代码
writer = csv.writer(file, delimiter='|', quoting=csv.QUOTE_ALL)

#生成的 CSV 文件的内容
"姓名"|"语文"|"数学"|"英语"
"小羽"|"88"|"64"|"65"
"小张"|"76"|"93"|"79"
"小赵"|"78"|"55"|"76"
"小马"|"72"|"77"|"68"
"小黄"|"70"|"72"|"51"

从 CSV 文件读取数据

如果要读取CSV 文件,通过csv模块的 reader 函数可以创建出csvreader对象,该对象是一个迭代器,可以通过next函数或for-in循环读取到文件中的数据

复制代码
import csv

with open('scores.csv', 'r') as file:
    reader = csv.reader(file, delimiter='|')
    for data_list in reader:
        print(reader.line_num, end='\t')
        for elem in data_list:
            print(elem, end='\t')
        print()

# 注意:上面的代码对csvreader对象做for循环时,每次会取出一个列表对象
# 该列表对象包含了一行中所有的字段

21、第二十一天

PDF(Portable Document Format)通常使用**.pdf**作为其扩展名。在日常开发工作中,最容易遇到的就是从 PDF 中读取文本内容以及用已有的内容生成PDF文档这两个任务

从PDF中提取文本

复制代码
# 使用PyPDF2的三方库来读取 PDF 文件

pip install PyPDF2

PyPDF2 没有办法从 PDF 文档中提取图像、图表或其他媒体,但它可以提取文本,并将其返回为 Python 字符串

复制代码
import PyPDF2

reader = PyPDF2.PdfReader('test.pdf')
for page in reader.pages:
    print(page.extract_text())

不过,PyPDF2不是所有PDF 文档都能提取出文字来,尤其是在提取中文的时候

复制代码
# 要从 PDF 文件中提取文本也可以直接使用三方的命令行工具

pip install pdfminer.six
pdf2text.py test.pdf

旋转和叠加页面

通过创建 PdfFileReader 对象的方式来读取 PDF 文档,该对象的 getPage方法可以获得PDF文档的指定页并得到一个PageObject对象,通过PageObject对象的 rotateClockwise 和rotateCounterClockwise 方法可以实现页面的顺时针和逆时针方向旋转,通过PageObject对象的addBlankPage方法可以添加一个新的空白页

复制代码
reader = PyPDF2.PdfReader('XGBoost.pdf')
writer = PyPDF2.PdfWriter()

for no, page in enumerate(reader.pages):
    if no % 2 == 0:
        new_page = page.rotate(-90)
    else:
        new_page = page.rotate(90)
    writer.add_page(new_page)

with open('temp.pdf', 'wb') as file_obj:
    writer.write(file_obj)

加密PDF文件

使用 PyPDF2 中的 PdfFileWrite 对象可以为PDF文档加密,会比较方便地给一系列的PDF文档设置统一的访问口令

复制代码
import PyPDF2

reader = PyPDF2.PdfReader('XGBoost.pdf')
writer = PyPDF2.PdfWriter()

for page in reader.pages:
    writer.add_page(page)
    
writer.encrypt('foobared')

with open('temp.pdf', 'wb') as file_obj:
    writer.write(file_obj)

批量添加水印

PageObject 对象还有一个名为 mergePage 的方法,可以两个 PDF 页面进行叠加,通过这个操作,很容易实现给PDF文件添加水印的功能

例如:在"XGBoost.pdf"文件添加一个水印,可以先准备好一个提供水印页面的 PDF 文件,然后将包含水印的 PageObject 读取出来,然后再循环遍历 "XGBoost.pdf" 文件的每个页,获取到PageObject对象,然后通过mergePage方法实现水印页和原始页的合并。还可以让还可以让奇数页和偶数页使用不同的水印

复制代码
reader1 = PyPDF2.PdfReader('XGBoost.pdf')
reader2 = PyPDF2.PdfReader('watermark.pdf')
writer = PyPDF2.PdfWriter()
watermark_page = reader2.pages[0]

for page in reader1.pages:
    page.merge_page(watermark_page)
    writer.add_page(page)

with open('temp.pdf', 'wb') as file_obj:
    writer.write(file_obj)

创建PDF文件

复制代码
# 创建 PDF 文档需要三方库reportlab的支持

pip install reportlab

from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas

pdf_canvas = canvas.Canvas('resources/demo.pdf', pagesize=A4)
width, height = A4

# 绘图
image = canvas.ImageReader('resources/guido.jpg')
pdf_canvas.drawImage(image, 20, height - 395, 250, 375)

# 显示当前页
pdf_canvas.showPage()

# 注册字体文件
pdfmetrics.registerFont(TTFont('Font1', 'resources/fonts/Vera.ttf'))
pdfmetrics.registerFont(TTFont('Font2', 'resources/fonts/青呱石头体.ttf'))

# 写字
pdf_canvas.setFont('Font2', 40)
pdf_canvas.setFillColorRGB(0.9, 0.5, 0.3, 1)
pdf_canvas.drawString(width // 2 - 120, height // 2, '你好,世界!')
pdf_canvas.setFont('Font1', 40)
pdf_canvas.setFillColorRGB(0, 1, 0, 0.5)
pdf_canvas.rotate(18)
pdf_canvas.drawString(250, 250, 'hello, world!')

# 保存
pdf_canvas.save()

22、第二十二天

在计算机中,将红、绿、蓝三种色光以不同的比例叠加来组合成其他的颜色(这三种颜色也被称为色光三原色),通常会将一个颜色表示为一个 RGB 值或 RGBA 值(其中的 A 表示 Alpha 通道,它决定了透过这个图像的像素,也就是透明度)

名称 RGB值 名称 RGB值
White(白) (255, 255, 255) Red(红) (255, 0, 0)
Green(绿) (0, 255, 0) Blue(蓝) (0, 0, 255)
Gray(灰) (128, 128, 128) Yellow(黄) (255, 255, 0)
Black(黑) (0, 0, 0) Purple(紫) (128, 0, 128)

像素(pixel):是构成图像的最小单位,即具有明确位置和色彩数值的不可分割的小方格,其颜色和位置共同决定了图像的最终呈现效果与大小

用Pillow 处理图像

Pillow 可以实现图像压缩和图像处理等各种操作,是从 PIL(著名的 Python 图像处理库) 发展出来的一个分支。Pillow 中最为重要的是Image类,可以通过Image模块的open函数来读取图像并获得Image类型的对象

复制代码
pip install pillow

1、读取和显示图像

复制代码
from PIL import Image

# 读取图像获得Image对象
image = Image.open('demo.png')
# 通过Image对象的format属性获得图像的格式
print(image.format) # PNG
# 通过Image对象的size属性获得图像的尺寸
print(image.size)   # (854, 486)
# 通过Image对象的mode属性获取图像的模式
print(image.mode)   # RGBA
# 通过Image对象的show方法显示图像
image.show()

2、剪裁图像

复制代码
# 通过Image对象的crop方法指定剪裁区域剪裁图像
image.crop((80, 20, 310, 360)).show()

3、生成缩略图

复制代码
# 通过Image对象的thumbnail方法生成指定尺寸的缩略图
image.thumbnail((128, 128))
image.show()

4、缩放和黏贴图像

复制代码
# 获得demo1的Image对象
demo1_image = Image.open('demo.png')
# 获得demon2的Image对象
demo2_image = Image.open('demo1.jpg')

demo2_head = demo2_image.crop((80, 20, 310, 360))
width, height = demo2_head.size
# 使用Image对象的resize方法修改图像的尺寸
# 使用Image对象的paste方法将demo2的头粘贴到demo1的照片上
demo1_image.paste(demo2_head.resize((int(width / 1.5), int(height / 1.5))), (172, 40))
demo1_image.show()

5、旋转和翻转

复制代码
image = Image.open('demo.png')
# 使用Image对象的rotate方法实现图像的旋转
image.rotate(45).show()
# 使用Image对象的transpose方法实现图像翻转
# Image.FLIP_LEFT_RIGHT - 水平翻转
# Image.FLIP_TOP_BOTTOM - 垂直翻转
image.transpose(Image.FLIP_TOP_BOTTOM).show()

6、操作像素

复制代码
for x in range(80, 310):
    for y in range(20, 360):
        # 通过Image对象的putpixel方法修改图像指定像素点
        image.putpixel((x, y), (128, 128, 128))
image.show()

7、滤镜效果

复制代码
from PIL import ImageFilter

# 使用Image对象的filter方法对图像进行滤镜处理
# ImageFilter模块包含了诸多预设的滤镜也可以自定义滤镜
image.filter(ImageFilter.CONTOUR).show()

使用Pillow绘图

Pillow 中有一个名为ImageDraw的模块,该模块的Draw函数会返回一个ImageDraw对象,通过ImageDraw对象的arc、line、rectangle、ellipse、polygon等方法,可以在图像上绘制出圆弧、线条、矩形、椭圆、多边形等形状,也可以通过该对象的text方法在图像上添加文字

复制代码
from PIL import Image, ImageDraw, ImageFont

# 创建一张 400x200 的白色图片
image = Image.new('RGB', (400, 200), (255, 255, 255))

# 创建画笔
draw = ImageDraw.Draw(image)

# 画一个红色矩形
draw.rectangle((50, 50, 350, 150), outline=(255, 0, 0), width=3)

# 写文字(如果字体文件找不到,可以去掉字体参数)
try:
    font = ImageFont.truetype('C:/Windows/Fonts/simhei.ttf', 30)
    draw.text((120, 80), 'Hello', fill=(0, 0, 255), font=font)
except:
    # 如果字体文件找不到,用默认字体
    draw.text((120, 80), 'Hello', fill=(0, 0, 255))

# 显示并保存
image.show()
image.save('simple.png')

除了可以用 Pillow 来处理图像外,还可以使用更为强大的 OpenCV 库来完成图形图像的处理,OpenCV(Open Source C omputer Vision Library)是一个跨平台的计算机视觉库,可以用来开发实时图像处理、计算机视觉和模式识别程序

23、第二十三天

正则表达式:可以定义字符串的匹配模式,即检查一个字符串是否有跟某种模式匹配的部分或者从一个字符串中将与模式匹配的部分提取出来或者替换掉

Python 通过标准库中的re模块来支持正则表达式操作

符号 解释 示例 说明
. 匹配任意字符 b.t 可以匹配bat / but / b#t / b1t等
\d 匹配数字 \d\d 可以匹配01 / 23 / 99等
\W 匹配非字母/数字/下划线 b\Wt 可以匹配b#t / b@t等 但不能匹配but / b1t / b_t等
\S 匹配非空白字符 love\Syou 可以匹配love#you等 但不能匹配love you
* 匹配0次或多次 \w*
+ 匹配1次或多次 \w+
? 匹配0次或1次 \w?
{M,N} 匹配至少M次至多N次 \w{3,6}

如果需要匹配的字符是正则表达式中的特殊字符,那么可以使用 \ 进行转义处理。例如:匹配小数点可以写成 \. ,因为直接写**.**会匹配任意字符;同理,想匹配圆括号必须写成\( 和 \),否则圆括号被视为正则表达式中的分组

Python对正则表达式的支持

Python 提供了re模块来支持正则表达式相关操作,下面是re模块中的核心函数

函数 说明
compile(pattern, flags=0) 编译正则表达式返回正则表达式对象
match(pattern, string, flags=0) 用正则表达式匹配字符串 成功返回匹配对象 否则返回None
search(pattern, string, flags=0) 搜索字符串中第一次出现正则表达式的模式 成功返回匹配对象 否则返回None
split(pattern, string, maxsplit=0, flags=0) 用正则表达式指定的模式分隔符拆分字符串 返回列表
sub(pattern, repl, string, count=0, flags=0) 用指定的字符串替换原字符串中与正则表达式匹配的模式 可以用count指定替换的次数
fullmatch(pattern, string, flags=0) match函数的完全匹配(从字符串开头到结尾)版本
findall(pattern, string, flags=0) 查找字符串所有与正则表达式匹配的模式 返回字符串的列表
finditer(pattern, string, flags=0) 查找字符串所有与正则表达式匹配的模式 返回一个迭代器
purge() 清除隐式编译的正则表达式的缓存
re.I / re.IGNORECASE 忽略大小写匹配标记
re.M / re.MULTILINE 多行匹配标记

例子1:验证输入用户名和QQ号是否有效并给出对应的提示信息

复制代码
# 用户名必须由字母、数字或下划线构成且长度在6~20个字符之间,QQ号是5~12的数字且首位不能为0
import re

username = input('请输入用户名: ')
qq = input('请输入QQ号: ')
# match函数的第一个参数是正则表达式字符串或正则表达式对象
# match函数的第二个参数是要跟正则表达式做匹配的字符串对象
m1 = re.match(r'^[0-9a-zA-Z_]{6,20}$', username)
if not m1:
    print('请输入有效的用户名.')
# fullmatch函数要求字符串和正则表达式完全匹配
# 所以正则表达式没有写起始符和结束符

m2 = re.fullmatch(r'[1-9]\d{4,11}', qq)
if not m2:
    print('请输入有效的QQ号.')
if m1 and m2:
    print('你输入的信息是有效的!')

# 代码在书写正则表达式时使用了"原始字符串"的写法(在字符串前面加上了r)
# 所谓"原始字符串"就是字符串中的每个字符都是它原始的意义
# 因为正则表达式中有很多元字符和需要进行转义的地方,
# 如果不使用原始字符串就需要将反斜杠写作\\,例如表示数字的\d得书写成\\d

例子2:拆分长字符串

复制代码
import re

poem = '窗前明月光,疑是地上霜。举头望明月,低头思故乡。'
sentences_list = re.split(r'[,。]', poem)
sentences_list = [sentence for sentence in sentences_list if sentence]
for sentence in sentences_list:
    print(sentence)

24、第二十四天

只有硬件没有软件的计算机系统被称之为"裸机",很难用"裸机"来完成计算机日常的工作(如存储和运算),所以必须用特定的软件来控制硬件的工作。最靠近计算机硬件的软件是系统软件,其中最为重要的就是"操作系统"。"操作系统"是控制和管理整个计算机硬件和软件资源、实现资源分配和任务调配、为系统用户以及其他软件提供接口和环境的程序的集合

Linux概述

Linux是一个通用操作系统。一个操作系统要负责任务调度、内存分配、处理外围设备I/O等操作。操作系统通常由内核(运行其他程序,管理像磁盘、打印机等硬件设备的核心程序)和系统程序(设备驱动、底层库、shell、服务程序等)两部分组成

Linux系统优点

  • 通用操作系统,不跟特定的硬件绑定
  • 用C语言编写,可移植性强,有内核编程接口
  • 支持多用户和多任务,支持安全的分层文件系统
  • 大量的实用程序,完善的网络功能以及强大的支持文档
  • 可靠的安全性和良好的稳定性,对开发者更友好

基础命令

Linux系统的命令通常都是如下所示的格式:命令名称 命名参数 命令对象

1、获取登录信息 - w / who / last/ lastb

2、查看自己使用的Shell - ps

3、查看命令的说明和位置 - whatis / which / whereis

4、清除屏幕上显示的内容 - clear

5、查看帮助文档 - man / info / --help / apropos

6、查看系统和主机名 - uname / hostname

7、时间和日期 - date / cal

8、重启和关机 - reboot / shutdown

说明:在执行shutdown命令时会向登录系统的用户发出警告,可以在命令后面跟上警告消息来替换默认的警告消息,也可以在-h参数后通过now来表示立刻关机

9、退出登录 - exit / logout

10、查看历史命令 - history

说明:查看到历史命令之后,可以用!历史命令编号来重新执行该命令;通过history -c可以清除历史命令

实用程序

文件和文件夹操作

1、创建/删除空目录 - mkdir / rmdir

2、创建/删除文件 - touch / rm

touch命令用于创建空白文件或修改文件时间,在Linux系统中一个文件有三种时间:

  • 更改内容的时间 - mtime
  • 更改权限的时间 - ctime
  • 最后访问时间 - atime

rm的几个重要参数:

  • -i:交互式删除,每个删除项都会进行询问
  • -r:删除目录并递归的删除目录中的文件和目录
  • -f:强制删除,忽略不存在的文件,没有任何提示

3、切换和查看当前工作目录 - cd / pwd

说明:cd命令后面可以跟相对路径(以当前路径作为参照)或绝对路径(以 / 开头)来切换到指定的目录,也可以用cd ..来返回上一级目录,如果要返回到上上一级目录,可以用cd ../..

4、查看目录内容 - ls

  • -l:以长格式查看文件和目录
  • -a:显示以点开头的文件和目录(隐藏文件)
  • -d:只列出目录,不列出其他内容
  • -S / -t:按大小/时间排序

5、查看文件内容 - cat / tac / head / tail / more / less / rev / od

6、拷贝/移动文件 - cp / mv

7、文件重命名 - rename

8、查找文件和查找内容 - find / grep

9、创建链接和查看链接 - ln / readlink

10、压缩/解压缩和归档/解归档 - gzip / gunzip / xz

11、归档和解归档 - tar

说明:归档(也称为创建归档)和解归档都使用tar命令,通常创建归档需要-cvf三个参数:其中c表示创建(create),v表示显示创建归档详情(verbose),f表示指定归档的文件(file);解归档需要加上-xvf参数,其中x表示抽取(extract),其他两个参数跟创建归档相同

12、将标准输入转成命令行参数 - xargs

复制代码
[root@iZwz97tbgo9lkabnat2lo8Z ~]# xargs < a.txt > b.txt
# 将a.txt文件中的多行内容变成一行输出到b.txt文件中
# 其中<表示从a.txt中读取输入
# >表示将命令的执行结果输出到b.txt中

13、显示文件或目录 - basename / dirname

14、其他相关工具

  • sort - 对内容排序
  • uniq - 去掉相邻重复内容
  • tr - 替换指定内容为新内容
  • cut / paste - 剪切/黏贴内容
  • split - 拆分文件
  • file - 判断文件类型
  • wc - 统计文件行数、单词数、字节数

管道和重定向

1、管道的使用 - |

复制代码
例子:查找当前目录下文件个数

[root ~]# find ./ | wc -l

6152

2、输出重定向和错误重定向 - > / >> / 2>

3、输入重定向 - <

复制代码
[root ~]# echo 'hello, world!' > hello.txt
[root ~]# wall < hello.txt
[root ~]#
Broadcast message from root (Wed Jun 20 19:43:05 2018):
hello, world!
[root ~]# echo 'I will show you some code.' >> hello.txt
[root ~]# wall < hello.txt
[root ~]#
Broadcast message from root (Wed Jun 20 19:43:55 2018):
hello, world!
I will show you some code.

# wall(write all):向当前系统中所有已登录的用户发送广播消息(通常用于系统维护通知)
# >:输出重定向符:它的作用是把命令的输出结果写入到后面的文件中
# >>:追加重定向符:和 > 不同,它不会覆盖文件,而是把新内容添加到文件末尾

4、多重定向 - tee

复制代码
# 除了在终端显示命令ls的结果之外,还会追加输出到ls.txt文件中

[root ~]# ls | tee -a ls.txt

文本处理

1、字符流编辑器 - sed

sed是操作、过滤和转换文本内容的工具

复制代码
[root ~]# cat -n fruit.txt

1  banana
2  grape
3  apple
4  watermelon
5  orange

# 在第2行后面添加一个pitaya:[root ~]# sed '2a pitaya' fruit.txt
# 在第2行前面插入一个waxberry:[root ~]# sed '2i waxberry' fruit.txt
# 删除第3行:[root ~]# sed '3d' fruit.txt
# 删除第2行到第4行:[root ~]# sed '2,4d' fruit.txt
# 将文本中的字符a替换为@:[root ~]# sed 's#a#@#' fruit.txt
# 将文本中的字符a替换为@,使用全局模式:[root ~]# sed 's#a#@#g' fruit.txt

2、模式匹配和处理语言 - awk

awk是一种编程语言,通过该命令可以从文本中提取出指定的列、用正则表达式从文本中取出想要的内容、显示指定的行以及进行统计和运算

复制代码
[root ~]# cat fruit2.txt

1       banana      120
2       grape       500
3       apple       1230
4       watermelon  80
5       orange      400

# 显示文件的第3行:[root ~]# awk 'NR==3' fruit2.txt
# 显示文件的第2列:[root ~]# awk '{print $2}' fruit2.txt
# 显示文件的最后一列:[root ~]# awk '{print $NF}' fruit2.txt
# 输出末尾数字大于等于300的行:[root ~]# awk '{if($3 >= 300) {print $0}}' fruit2.txt

用户管理

1、创建和删除用户 - useradd / userdel

  • -d - 创建用户时为用户指定用户主目录
  • -g - 创建用户时指定用户所属的用户组

2、创建和删除用户组 - groupadd / groupdel

用户组主要是为了方便对一个组里面所有用户的管理

3、修改密码 - passwd

输入密码和确认密码没有回显且必须一气呵成的输入完成(不能使用退格键),密码和确认密码需要一致。如果使用passwd命令时没有指定命令作用的对象,则表示要修改当前用户的密码。如果想批量修改用户密码,可以使用chpasswd命令

  • -l / -u - 锁定/解锁用户
  • -d - 清除用户密码
  • -e - 设置密码立即过期,用户登录时会强制要求修改密码
  • -i - 设置密码过期多少天以后禁用该用户

4、查看和修改密码有效期 - chage

复制代码
# 设置hellokitty用户100天后必须修改密码,过期前15天通知该用户,过期后7天禁用该用户

chage -M 100 -W 15 -I 7 hellokitty

5、切换用户 - su

6、以管理员身份执行命令 - sudo

如果希望用户能够以管理员身份执行命令,用户必须要出现在sudoers名单中,sudoers文件在 /etc目录下

7、编辑sudoers文件 - visudo

8、显示用户与用户组的信息 - id

9、给其他用户发消息 -write / wall

10、查看/设置是否接收其他用户发送的消息 - mesg

文件系统

文件和路径

  • 命名规则:文件名的最大长度与文件系统类型有关,文件名不应该超过255个字符(一般情况下),绝大多数字符都可以用于文件名,不过最好使用英文大小写字母、数字、下划线、点这样的符号,同时应该避免在文件名中使用空格,或者在输入文件名时需要用将文件名放在双引号中或者通过\对空格进行转义
  • 扩展名:在Linux系统下文件的扩展名是可选的,有助于对文件内容的理解。有些应用程序要通过扩展名来识别文件,但是更多的应用程序并不依赖文件的扩展名,就像file命令在识别文件时并不是依据扩展名来判定文件的类型
  • 隐藏文件:以点开头的文件在Linux系统中是隐藏文件(不可见文件)

访问权限

1、chmod - 改变文件模式比特

复制代码
[root ~]# chmod g+w,o+w sohu.html

[root ~]# chmod 644 sohu.html

用chmod改变文件模式比特有两种方式:一种是字符设定法,另一种是数字设定法
除了chmod之外,可以通过umask来设定哪些权限将在新文件的默认权限中被删除

2、chown - 改变文件所有者

复制代码
[root ~]# ls -l
-rw-r--r--  1 root root     54 Jun 20 10:06 readme.txt
[root ~]# chown hellokitty readme.txt
[root ~]# ls -l
-rw-r--r--  1 hellokitty root     54 Jun 20 10:06 readme.txt

3、chgrp - 改变用户组

磁盘管理

1、列出文件系统的磁盘使用状况 - df

2、磁盘分区表操作 - fdisk

3、磁盘分区工具 - parted

4、格式化文件系统 - mkfs

  • -t - 指定文件系统的类型

  • -c - 创建文件系统时检查磁盘损坏情况

  • -v - 显示详细信息

    [root ~]# mkfs -t ext4 -v /dev/sdb

5、文件系统检查 - fsck

6、转换或拷贝文件 - dd

7、挂载/卸载 - mount / umount

8、创建/激活/关闭交换分区 - mkswap / swapon / swapoff

执行上面这些命令会带有一定的风险,在不清楚这些命令的用法,最好不用随意使用,在使用的过程中,最好对照参考资料进行操作,并在操作前确认要这么做

编辑器 - vim

1、启动vim:可以通过vi或vim命令来启动vim,启动时可以指定文件名来打开一个文件,如果没有指定文件名,也可以在保存的时候指定文件名

2、命令模式、编辑模式和末行模式:启动vim进入的是命令模式(也称为Normal模式),在命令模式下输入英文字母i会进入编辑模式(Insert模式),屏幕下方出现-- INSERT --提示;在编辑模式下按下Esc会回到命令模式,此时如果输入英文**:**会进入末行模式,在末行模式下输入q! 可以在不保存当前工作的情况下强行退出vim;在命令模式下输入v会进入可视模式(Visual模式),可以用光标选择一个区域再完成对应的操作

3、保存和退出vim:在命令模式下输入**:**进入末行模式,输入wq可以实现保存退出;如果想放弃编辑的内容输入q! 强行退出;在命令模式下也可以直接输入ZZ实现保存退出。如果只想保存文件不退出,那么可以在末行模式下输入w;可以在w后面输入空格再指定要保存的文件名

4、光标操作

  • 在命令模式下可以通过h、j、k、l来控制光标向左、下、上、右的方向移动,可以在字母前输入数字来表示移动的距离,例如:10h表示向左移动10个字符
  • 在命令模式下可以通过Ctrl+y和Ctrl+e来实现向上、向下滚动一行文本的操作,可以通过Ctrl+f 和Ctrl+b来实现向前和向后翻页的操作
  • 在命令模式下可以通过输入英文字母G将光标移到文件的末尾,可以通过gg将光标移到文件的开始,也可以通过在G前输入数字来将光标移动到指定的行

软件安装和配置

使用包管理工具

1、yum - Yellowdog Updater Modified

  • yum search:搜索软件包,例如:yum search nginx
  • yum list installed:列出已经安装的软件包,例如:yum list installed | grep zlib
  • yum install:安装软件包,例如:yum install nginx
  • yum remove:删除软件包,例如:yum remove nginx
  • yum update:更新软件包,例如:yum update可以更新所有软件包,而yum update tar只会更新tar
  • yum check-update:检查有哪些可以更新的软件包
  • yum info:显示软件包的相关信息,例如:yum info nginx

2、rpm - Redhat Package Manager

  • 安装软件包:rpm -ivh <packagename>.rpm
  • 移除软件包:rpm -e <packagename>
  • 查询软件包:rpm -qa,例如可以用rpm -qa | grep mysql,检查是否安装了MySQL相关软件包

配置服务

可以Linux系统下安装和配置各种服务,也就是说可以把Linux系统打造成数据库服务器、Web服务器、缓存服务器、文件服务器、消息队列服务器等等。Linux下的大多数服务都被设置为守护进程(驻留在系统后台运行,但不会因为服务还在运行而导致Linux无法停止运行),所以安装的服务通常名字后面都有一个字母d(daemon),例如:防火墙服务叫firewalld,MySQL服务叫mysqld,Apache服务器叫httpd等。在安装好服务之后,可以使用systemctl命令或Service命令来完成对服务的启动、停止等操作

复制代码
启动防火墙服务:[root ~]# systemctl start firewalld

终止防火墙服务:[root ~]# systemctl stop firewalld

重启防火墙服务:[root ~]# systemctl restart firewalld

查看防火墙服务状态:[root ~]# systemctl status firewalld

设置/禁用防火墙服务开机自启:
[root ~]# systemctl enable firewalld
[root ~]# systemctl disable firewalld

网络访问和管理

1、安全远程连接 - ssh

复制代码
[root ~]$ ssh root@120.77.222.217

2、通过网络获取资源 - wget

  • -b 后台下载模式
  • -O 下载到指定的目录
  • -r 递归下载

3、发送和接收邮件 - mail

4、网络配置工具(旧) - ifconfig

5、网络配置工具(新) - ip

6、网络可达性检查 - ping

7、显示或管理路由表 - route

8、查看网络服务和端口 - netstat / ss

9、网络监听抓包 - tcpdump

进程管理

1、查看进程 - ps

复制代码
[root ~]# ps -ef
[root ~]# ps -ef | grep mysqld

2、显示进程状态树 - pstree

3、查找与指定条件匹配的进程 - pgrep

4、通过进程号终止进程 - kill

复制代码
[root ~]$ kill -l 
[root ~]# kill 1234
[root ~]# kill -9 1234

5、通过进程名终止进程 - killall / pkill

复制代码
结束名为mysqld的进程:[root ~]# pkill mysqld

结束hellokitty用户的所有进程:[root ~]# pkill -u hellokitty

# 这样的操作会让hellokitty用户和服务器断开连接。

6、将进程置于后台运行

  • Ctrl+Z - 快捷键,用于停止进程并置于后台

  • & - 将进程置于后台运行

    [root ~]# mongod &

    [root ~]# redis-server

7、查询后台进程 - jobs

8、调整程序/进程运行时优先级 - nice / renice

复制代码
[root ~]# nohup ping www.baidu.com > result.txt &

9、跟踪进程系统调用情况 - strace

复制代码
[root ~]# pgrep mysqld
8803
[root ~]# strace -c -p 8803
strace: Process 8803 attached
^Cstrace: Process 8803 detached

10、查看当前运行级别 - runlevel

11、实时监控进程占用资源状况 - top

复制代码
[root ~]# top

-c - 显示进程的整个路径
-d - 指定两次刷屏之间的间隔时间(秒为单位)
-i - 不显示闲置进程或僵尸进程
-p - 显示指定进程的信息

系统诊断

1、系统启动异常诊断 - dmesg

2、查看系统活动信息 - sar

  • -A - 显示所有设备(CPU、内存、磁盘)的运行状况
  • -u - 显示所有CPU的负载情况
  • -d - 显示所有磁盘的使用情况
  • -r - 显示内存的使用情况
  • -n - 显示网络运行状态

3、查看内存使用情况 - free

4、虚拟内存统计 - vmstat

5、CPU信息统计 - mpstat

6、查看进程使用内存状况 - pmap

Shell编程

Shell是一个连接用户和操作系统的应用程序,它提供了人机交互的界面(接口),用户通过这个界面访问操作系统内核的服务。Shell脚本是一种为Shell编写的脚本程序,可以通过Shell脚本来进行系统管理,同时也可以通过它进行文件操作

复制代码
# 输入两个整数m和n,计算从m到n的整数求和的结果
#!/usr/bin/bash
printf 'm = '
read m
printf 'n = '
read n
a=$m
sum=0
while [ $a -le $n ]
do
    sum=$[ sum + a ]
    a=$[ a + 1 ]
done
echo '结果: '$sum

# 自动安装指定版本的Redis

#!/usr/bin/bash
install_redis() {
    if ! which redis-server > /dev/null
    then
        cd /root
        wget $1$2'.tar.gz' >> install.log
        gunzip /root/$2'.tar.gz'
        tar -xf /root/$2'.tar'
        cd /root/$2
        make >> install.log
        make install >> install.log
        echo '安装完成'
    else
        echo '已经安装过Redis'
    fi
}

install_redis 'http://download.redis.io/releases/' $1

25、第二十五天

关系型数据库概述

  • 数据持久化 - 将数据保存到能够长久保存数据的存储介质中,在掉电的情况下数据也不会丢失
  • 数据库发展史 - 网状数据库、层次数据库、关系数据库、NoSQL 数据库、NewSQL 数据库

关系数据库特点:

  • 理论基础:关系代数(集合论、一阶谓词、关系运算)
  • 具体表象:用二维表(有行和列)组织数据
  • 编程语言:结构化查询语言(SQL)

结构化查询语言包括:DDL(数据定义语言)、DML(数据操作语言)、DCL(数据控制语言)、TCL(事务控制语言)

MySQL 简介

MySQL在过去由于性能高、成本低、可靠性好,成为最流行的开源数据库,因此被广泛地应用于中小型网站开发。随着 MySQL 的不断成熟,它也逐渐被应用于更多大规模网站和应用,提供数据持久化服务

MySQL 基本命令

1、查看命令

  • 查看所有数据库:show databases
  • 查看所有字符集:show character set
  • 查看所有的排序规则:show collation
  • 查看所有的引擎:show engines
  • 查看所有日志文件:show binary logs
  • 查看数据库下所有表:show tables

2、获取帮助

在 MySQL 命令行工具中,可以使用help命令或?来获取帮助

  • 查看show命令的帮助:? show
  • 查看有哪些帮助内容:? contents
  • 获取函数的帮助:? functions
  • 获取数据类型的帮助:? data types

26、第二十六天

通常可以将 SQL 分为四类,分别是 DDL(数据定义语言)、DML(数据操作语言)、 DCL(数据控制语言)和 TCL(事务控制语言)

  • DDL:主要用于创建、删除、修改数据库中的对象,核心的关键字包括create、drop和alter
  • DML:主要负责数据的插入、删除、更新和查询,关键词包括insert、delete、update和select
  • DCL:用于授予和召回权限,核心关键词是grant和revoke
  • TCL:通常用于事务控制

SQL 是不区分大小写的语言,一般会将关键字大写,其他部分小写

建库建表

实现一个学校选课系统的数据库,数据库命名为school,四个关键的实体分别是学院、老师、学生和课程,其中,学生跟学院是从属关系,这个关系从数量上来讲是多对一关系,因为一个学院可以有多名学生,而一个学生通常只属于一个学院。简单起见,将课程和老师设计为多对一关系。学生和课程是典型的多对多关系,因为一个学生可以选择多门课程,一门课程也可以被多个学生选择,而关系型数据库需要借助中间表才能维持维持两个实体的多对多关系。最终,学校选课系统一共有五张表,分别是学院表(tb_college)、学生表(tb_student)、教师表(tb_teacher)、课程表(tb_course)和选课记录表(tb_record),其中选课记录表就是维持学生跟课程多对多关系的中间表

复制代码
-- 如果存在名为school的数据库就删除它
DROP DATABASE IF EXISTS `school`;

-- 创建名为school的数据库并设置默认的字符集和排序方式
CREATE DATABASE `school` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

-- 切换到school数据库上下文环境
USE `school`;

-- 创建学院表
CREATE TABLE `tb_college`
(
`col_id`    int unsigned AUTO_INCREMENT      COMMENT '编号',
`col_name`  varchar(50)  NOT NULL            COMMENT '名称',
`col_intro` varchar(500) NOT NULL DEFAULT '' COMMENT '介绍',
PRIMARY KEY (`col_id`)
);

-- 创建学生表
CREATE TABLE `tb_student`
(
`stu_id`    int unsigned NOT NULL           COMMENT '学号',
`stu_name`  varchar(20)  NOT NULL           COMMENT '姓名',
`stu_sex`   boolean      NOT NULL DEFAULT 1 COMMENT '性别',
`stu_birth` date         NOT NULL           COMMENT '出生日期',
`stu_addr`  varchar(255) DEFAULT ''         COMMENT '籍贯',
`col_id`    int unsigned NOT NULL           COMMENT '所属学院',
PRIMARY KEY (`stu_id`),
CONSTRAINT `fk_student_col_id` FOREIGN KEY (`col_id`) REFERENCES `tb_college` (`col_id`)
);

-- 创建教师表
CREATE TABLE `tb_teacher`
(
`tea_id`    int unsigned NOT NULL                COMMENT '工号',
`tea_name`  varchar(20)  NOT NULL                COMMENT '姓名',
`tea_title` varchar(10)  NOT NULL DEFAULT '助教' COMMENT '职称',
`col_id`    int unsigned NOT NULL                COMMENT '所属学院',
PRIMARY KEY (`tea_id`),
CONSTRAINT `fk_teacher_col_id` FOREIGN KEY (`col_id`) REFERENCES `tb_college` (`col_id`)
);

-- 创建课程表
CREATE TABLE `tb_course`
(
`cou_id`     int unsigned NOT NULL COMMENT '编号',
`cou_name`   varchar(50)  NOT NULL COMMENT '名称',
`cou_credit` int          NOT NULL COMMENT '学分',
`tea_id`     int unsigned NOT NULL COMMENT '授课老师',
PRIMARY KEY (`cou_id`),
CONSTRAINT `fk_course_tea_id` FOREIGN KEY (`tea_id`) REFERENCES `tb_teacher` (`tea_id`)
);

-- 创建选课记录表
CREATE TABLE `tb_record`
(
`rec_id`   bigint unsigned AUTO_INCREMENT COMMENT '选课记录号',
`stu_id`   int unsigned    NOT NULL       COMMENT '学号',
`cou_id`   int unsigned    NOT NULL       COMMENT '课程编号',
`sel_date` date            NOT NULL       COMMENT '选课日期',
`score`    decimal(4,1)                   COMMENT '考试成绩',
PRIMARY KEY (`rec_id`),
CONSTRAINT `fk_record_stu_id` FOREIGN KEY (`stu_id`) REFERENCES `tb_student` (`stu_id`),
CONSTRAINT `fk_record_cou_id` FOREIGN KEY (`cou_id`) REFERENCES `tb_course` (`cou_id`),
CONSTRAINT `uk_record_stu_cou` UNIQUE (`stu_id`, `cou_id`)
);
  • 首先,SQL 中的数据库名、表名、字段名都被反引号(`)包裹起来,反引号并不是必须的,但是却可以解决表名、字段名等跟 SQL 关键字冲突的问题

  • 创建数据库时,通过default character set utf8mb4 指定了数据库默认使用的字符集为utf8mb4,也是 MySQL 8.x 默认使用的字符集,因为能够支持国际化编码,还可以存储 Emoji 字符,可以通过命令查看 MySQL 支持的字符集以及默认的排序规则

    show character set;

  • 创建和删除数据库时,关键字database也可以替换为schema,二者作用相同

  • 建表语句中的not null是非空约束,它限定了字段不能为空;default用于为字段指定默认值,称之为默认值约束;primary key是主键约束,它设定了能够唯一确定一条记录的列,也确保了每条记录都是独一无二的,因为主键不允许重复;foreign key是外键约束,它维持了两张表的参照完整性。如果需要给主键约束、外键约束等起名字,可以使用constriant关键字并在后面跟上约束的名字

  • 建表语句中的comment 关键字用来给列和表添加注释,增强代码的可读性和可维护性

  • 在创建表的时候,可以自行选择底层的存储引擎,MySQL 支持多种存储引擎,可以通过show engines命令进行查看。MySQL 5.5 以后的版本默认使用的存储引擎是 InnoDB,也是推荐使用的存储引擎(高并发、性能以及事务支持),可以在建表语句结束处右圆括号的后面通过engine=innodb 来指定使用 InnoDB 存储引擎

下面的表格对MySQL几种常用的数据引擎进行了简单的对比

特性 InnoDB MRG_MYISAM MEMORY MyISAM
存储限制 没有
事务 支持
锁机制 行锁 表锁 表锁 表锁
B树索引 支持 支持 支持 支持
哈希索引 支持
全文检索 支持(5.6+) 支持
集群索引 支持
数据缓存 支持 支持
索引缓存 支持 支持 支持 支持
数据可压缩 支持
内存使用
存储空间使用
批量插入性能
是否支持外键 支持

InnoDB 是唯一能够支持外键、事务以及行锁的存储引擎,而且在较新版本的 MySQL 中,它也是默认使用的存储引

在数据类型的选择上,保存字符串数据通常都使用 VARCHAR 和 CHAR 两种类型,前者通常称为变长字符串,而后者通常称为定长字符串;对于 InnoDB 存储引擎,行存储格式没有区分固定长度和可变长度列,因此 VARCHAR 类型和 CHAR 类型没有本质区别,后者不一定比前者性能更好。如果要保存的很大字符串,可以使用 TEXT 类型;如果要保存很大的字节串,可以使用 BLOB(二进制大对象)类型。在 MySQL 中,TEXT 和 BLOB又分别包括 TEXT、MEDIUMTEXT、LONGTEXT 和 BLOB、MEDIUMBLOB、LONGBLOB 三种不同的类型,主要的区别在于存储数据的最大大小不同。保存浮点数可以用 FLOAT(不推荐使用) 或 DOUBLE 类型,而保存定点数应该使用 DECIMAL 类型,它可以指定小数点前后有效数字的位数。如果要保存时间日期,DATETIME 类型优于 TIMESTAMP 类型,因为前者能表示的时间日期范围更大,后者底层其实就是一个整数,记录了指定的日期时间和 1970-01-01 00:00:00 相差多少个毫秒,该类型在 2038-01-19 03:14:07 之后就会溢出

对于自增字段 AUTO_INCREMENT,在MySQL 8.x 上不用考虑自增字段的回溯问题。对于高并发访问数据库的场景,AUTO_INCREMENT 不仅存在性能上的问题,还可能在多机结构上产生重复的 ID 值,在这种场景下,使用分布式 ID 生成算法(SnowFlake、TinyID等)才是最好的选择

删除表和修改表

删除表可以使用 drop table

复制代码
DROP TABLE `tb_student`;
或
DROP TABLE IF EXISTS `tb_student`;

# 需要注意的是,如果学生表已经录入了数据而且该数据被其他表引用了
# 那么就不能删除学生表,否则上面的操作会报错

如果要修改学生表,可以使用 alter table,具体可以分为以下几种情况:

复制代码
# 修改表,添加一个新列,例如给学生表添加一个联系电话的列

ALTER TABLE `tb_student` ADD COLUMN `stu_tel` varchar(20) NOT NULL COMMENT '联系电话';

# 注意:
# 如果新增列的时候指定了非空约束(not null),那么学生表不能够有数据,否则原来的数据增加了 stu_tel 列之后是没有数据的,这就违反了非空约束的要求
# 当然,在添加列的时候也可以使用默认值约束来解决这个问题

​# 修改表,删除指定的列,例如将上面添加的联系电话列删除掉
ALTER TABLE `tb_student` DROP COLUMN `stu_tel`;

# 修改表,修改列的数据类型,例如将学生表的 stu_sex 修改为字符
ALTER TABLE `tb_student` MODIFY COLUMN `stu_sex` char(1) NOT NULL DEFAULT 'M' COMMENT '性别';

# 修改表,修改列的命名,例如将学生表的 stu_sex 修改为 stu_gender
ALTER TABLE `tb_student` CHANGE COLUMN `stu_sex` `stu_gender` boolean DEFAULT 1 COMMENT '性别';

# 修改表,删除约束条件,例如删除学生表的 col_id 列的外键约束
ALTER TABLE `tb_student` DROP FOREIGN KEY `fk_student_col_id`;

# 修改表,添加约束条件,例如给学生表的 col_id 列加上外键约束
ALTER TABLE `tb_student` ADD FOREIGN KEY (`col_id`) REFERENCES `tb_college` (`col_id`);

ALTER TABLE `tb_student` ADD CONSTRAINT `fk_student_col_id` FOREIGN KEY (`col_id`) REFERENCES `tb_college` (`col_id`);

说明:在添加外键约束时,还可以通过on update 和 on delete 来指定在被引用的表发生删除和更新操作时,应该进行何种处理,二者的默认值都是restrict,表示如果存在外键约束,则不允许更新和删除被引用的数据

修改表的名字,例如将学生表的名字修改为 tb_stu_info。一般情况下,请不要轻易修改数据库或表的名字

复制代码
ALTER TABLE `tb_student` RENAME TO `tb_stu_info`;

27、第二十七天

DML 可以帮助将数据插入到二维表(insert操作)、从二维表删除数据(delete操作)以及更新二维表的数据(update操作)

复制代码
# 在执行 DML 之前,通过下面的use命令切换到school数据库
USE `school`;

insert操作

insert是用来插入行到二维表中的,插入的方式包括:插入完整的行、插入行的一部分、插入多行、插入查询的结果

复制代码
# 如下所示的 SQL 向学院表中添加一个学院

INSERT INTO `tb_college`
VALUES
    (DEFAULT, '计算机学院', '学习计算机科学与技术的地方');

# 由于学院表的主键是一个自增字段,因此上面的 SQL 中用default表示该列使用默认值

INSERT INTO `tb_college` (`col_name`, `col_intro`)
VALUES
    ('计算机学院', '学习计算机科学与技术的地方');

推荐使用下面这种做法,指定为哪些字段赋值,这样做可以不按照建表时设定的字段顺序赋值,可以按照 values 前面的元组中给定的字段顺序为字段赋值,但是需要注意,除了允许为null和有默认值的字段外,其他的字段都必须要一一列出并在values后面的元组中为其赋值。如果希望一次性插入多条记录,可以在values后面跟上多个元组来实现批量插入

复制代码
INSERT INTO `tb_college`
    (`col_name`, `col_intro`)
VALUES
    ('外国语学院', '学习歪果仁的语言的学院'),
    ('经济管理学院', '经世济民,治理国家;管理科学,兴国之道'),
    ('体育学院', '发展体育运动,增强人民体质');

在插入数据时,要注意主键是不能重复的,如果插入的数据与表中已有记录主键相同,那么insert操作将会产生 Duplicated Entry 的报错信息。如果insert操作省略了某些列,那么这些列要么有默认值,要么允许为 null,否则也将产生错误。在业务系统中,为了让insert 操作不影响其他操作(select)的性能可以在 insert 和 into之间加一个 low_priority 来降低insert操作的优先级,这个做法也适用于delete和update操作

delete 操作

如果需要从表中删除数据,可以使用delete操作,可以删除指定行或所有行

复制代码
# 删除编号为1的学院
DELETE
  FROM `tb_college`
WHERE col_id=1;

# delete操作中的where子句是用来指定条件的,只有满足条件的行会被删除
# 如果不加条件的话,就会删除学院表中所有的记录,还是比较危险的

DELETE
  FROM `tb_college`;

即便删除了所有的数据,delete操作不会删除表本身,也不会让 AUTO_INCREMENT 字段的值回到初始值。如果需要删除所有的数据而且让 AUTO_INCREMENT 字段回到初始值,可以使用truncate table执行截断表操作,truncate的本质是删除原来的表并重新创建一个表,速度其实更快,因为不需要逐行删除数据。用truncate table删除数据是非常危险的,因为会删除所有的数据,而且由于原来的表已经被删除了,要想恢复误删除的数据也会变得极为困难

update 操作

如果要修改表中的数据,可以使用update操作,它可以用来修改指定的行或所有的行

复制代码
# 将学生表中的"杨过"修改为"杨逍",这里假设"杨过"的学号为1001

UPDATE `tb_student`
   SET `stu_name`='杨逍'
 WHERE `stu_id`=1001;

需要注意的是,update操作中的set关键字,因为 SQL 中的=并不表示赋值,而是判断相等的运算符,只有出现在set 关键字后面的=,才具备赋值的能力

复制代码
# 同时修改学生的姓名和生日

UPDATE `tb_student`
   SET `stu_name`='杨逍'
     , `stu_birth`='1975-12-29'
 WHERE `stu_id`=1001;

update语句中也可以使用查询的方式获得数据并以此来更新指定的表数据,在书写update语句时,通常都会有where子句,因为实际工作中几乎不太会用到更新全表的操作

28、第二十八天

MySQL目前的版本不支持全外连接,通过union操作,将左外连接和右外连接的结果求并集实现全外连接的效果

MySQL 中支持多种类型的运算符,包括:算术运算符(+、-、*、/、%)、比较运算符(=、<>、<=>、<、<=、>、>=BETWEEN...AND...、IN、IS NULL、IS NOT NULL、LIKE、RLIKE、REGEXP)、逻辑运算符(NOT、AND、OR、XOR)和位运算符(&、|、^、~、>>、<<)

在查询数据时,可以在SELECT语句及其子句(如WHERE子句、ORDER BY子句、HAVING子句等)中使用函数,这些函数包括字符串函数、数值函数、时间日期函数、流程函数等

常用字符串函数

函数 功能
CONCAT 将多个字符串连接成一个字符串
FORMAT 将数值格式化成字符串并指定保留几位小数
FROM_BASE64 / TO_BASE64 BASE64解码/编码
BIN / OCT / HEX 将数值转换成二进制/八进制/十六进制字符串
LOCATE 在字符串中查找一个子串的位置
LEFT / RIGHT 返回一个字符串左边/右边指定长度的字符
LENGTH / CHAR_LENGTH 返回字符串的长度以字节/字符为单位
LOWER / UPPER 返回字符串的小写/大写形式
LPAD / RPAD 如果字符串的长度不足,在字符串左边/右边填充指定的字符
LTRIM / RTRIM 去掉字符串前面/后面的空格
ORD / CHAR 返回字符对应的编码/返回编码对应的字符
STRCMP 比较字符串,返回-1、0、1分别表示小于、等于、大于
SUBSTRING 返回字符串指定范围的子串

常用数值函数

函数 功能
ABS 返回一个数的绝度值
CEILING / FLOOR 返回一个数上取整/下取整的结果
CONV 将一个数从一种进制转换成另一种进制
CRC32 计算循环冗余校验码
EXP / LOG / LOG2 / LOG10 计算指数/对数
POW 求幂
RAND 返回[0,1)范围的随机数
ROUND 返回一个数四舍五入后的结果
SQRT 返回一个数的平方根
TRUNCATE 截断一个数到指定的精度
SIN / COS / TAN / COT / ASIN / ACOS / ATAN 三角函数

常用时间日期函数

函数 功能
CURDATE / CURTIME / NOW 获取当前日期/时间/日期和时间
ADDDATE / SUBDATE 将两个日期表达式相加/相减并返回结果
DATE / TIME 从字符串中获取日期/时间
YEAR / MONTH / DAY 从日期中获取年/月/日
HOUR / MINUTE / SECOND 从时间中获取时/分/秒
DATEDIFF / TIMEDIFF / TIMESTAMPDIFF 返回两个时间日期表达式相差多少天/小时
MAKEDATE / MAKETIME 制造一个日期/时间

常用流程控制函数

函数 功能
IF 根据条件是否成立返回不同的值
IFNULL 如果为NULL则返回指定的值否则就返回本身
NULLIF 两个表达式相等就返回NULL否则返回第一个表达式的值

其他常用函数

函数 功能
MD5 / SHA1 / SHA2 返回字符串对应的哈希摘要
CHARSET / COLLATION 返回字符集/校对规则
USER / CURRENT_USER 返回当前用户
DATABASE 返回当前数据库名
VERSION 返回当前数据库版本
FOUND_ROWS / ROW_COUNT 返回查询到的行数/受影响的行数
LAST_INSERT_ID 返回最后一个自增主键的值
UUID / UUID_SHORT 返回全局唯一标识符

29、第二十九天

DCL可以为指定的用户授予访问权限或者从指定用户处召回指定的权限,对数据库管理员来说非常重要,因为用户权限的管理关系到数据库的安全。简单的说,可以通过 DCL 允许受信任的用户访问数据库,阻止不受信任的用户访问数据库,同时还可以通过 DCL 将每个访问者的的权限最小化

创建用户

复制代码
# 创建一个用户并为其指定访问口令
CREATE USER 'xiaobai'@'%' IDENTIFIED BY 'Wang.618';

# SQL 创建了名为xiaobai 的用户,它的访问口令是 Wang.618
# 该用户可以从任意主机访问数据库服务器,因为 @ 后面使用了可以表示任意多个字符的通配符 %

如果要限制用户只能从 192.168.0.x 这个网段的主机访问数据库服务器

复制代码
DROP USER IF EXISTS 'xiaobai'@'%';

CREATE USER 'xiaobai'@'192.168.0.%' IDENTIFIED BY 'Wang.618';

授予权限

复制代码
# 为 wangdachui 授予查询 school 数据库学院表(tb_college)的权限
GRANT SELECT ON `school`.`tb_college` TO 'xiaobai '@'192.168.0.%';

# 还可以让 xiaobai  对 school 数据库的所有对象都具有查询权限
GRANT SELECT ON `school`.* TO 'xiaobai '@'192.168.0.%';

# 如果希望 xiaobai 还有 insert、delete 和 update 权限,可以使用下面的方式进行操作
GRANT INSERT, DELETE, UPDATE ON `school`.* TO 'xiaobai '@'192.168.0.%';

# 如果还想授予 xiaobai  执行 DDL 的权限
GRANT CREATE, DROP, ALTER ON `school`.* TO 'xiaobai '@'192.168.0.%';

# 如果希望对所有数据库的所有对象都具备所有的操作权限
# 但是一般情况下,不会这样做,因为一个普通的账号不应该拥有这么大的权限

GRANT ALL PRIVILEGES ON *.* TO 'xiaobai '@'192.168.0.%';

召回权限

复制代码
# 如果要召回对 school 数据库的 insert、delete 和 update 权限
REVOKE INSERT, DELETE, UPDATE ON `school`.* FROM 'xiaobai '@'192.168.0.%';

# 如果要召回所有的权限
REVOKE ALL PRIVILEGES ON *.* FROM 'xiaobai '@'192.168.0.%';

# 由于数据库可能会缓存用户的权限,可以在授予或召回权限后执行下面的语句使新的权限即时生效
FLUSH PRIVILEGES;

30、第三十天

由于结构化的存储缺乏灵活性,是因为事先设计好所有的列以及对应的数据类型,如果需要修改表结构,是比较麻烦的。从 MySQL 5.7 版本开始,MySQL引入了对 JSON 数据类型的支持,其实就是打破了关系型数据库和非关系型数据库之间的界限,为数据持久化操作带来了更多的便捷

JSON 类型主要分为 JSON 对象和 JSON数组两种

复制代码
​# JSON 对象
{"name": "小白", "tel": "13800908808", "QQ": "8888888"}

# JSON 数组
[{"name": "小白", "tel": "13800008888"}, {"name": "小黄", "QQ": "123456"}]

目前很多产品支持用户多种的的登录方式(手机号,微信,QQ等),但是又不会让用户提供所有的这些信息。如果用传统的设计方式,就需要多个列来对应多种登录方式,可能还需要允许这些列存在空值;另一方面,如果产品又增加了一种登录方式,那么就必然要修改之前的表结构。JSON就能很简单地解决上述问题

复制代码
CREATE TABLE `tb_test`
(
`user_id`    bigint unsigned,
`login_info` json,
PRIMARY KEY (`user_id`)
);

INSERT INTO `tb_test` 
VALUES 
    (1, '{"tel": "13122335566", "QQ": "654321", "wechat": "jackfrued"}'),
    (2, '{"tel": "13599876543", "weibo": "xiaobai123"}');

# 如果要查询用户的手机和微信号,可以用如下的 SQL 语句

SELECT `user_id`
     , JSON_UNQUOTE(JSON_EXTRACT(`login_info`, '$.tel')) AS 手机号
     , JSON_UNQUOTE(JSON_EXTRACT(`login_info`, '$.wechat')) AS 微信 
FROM `tb_test`;

+---------+-------------+-----------+
| user_id | 手机号      | 微信       |
+---------+-------------+-----------+
|       1 | 13122335566 | jackfrued |
|       2 | 13599876543 | NULL      |
+---------+-------------+-----------+

窗口函数

MySQL 从8.0开始支持窗口函数,大多数商业数据库和一些开源数据库早已提供了对窗口函数的支持,有的也将其称之为 OLAP(联机分析和处理)函数,是与统计和分析相关

窗口可以理解为记录的集合,窗口函数也就是在满足某种条件的记录集合上执行的特殊函数,对于每条记录都要在此窗口内执行函数。窗口函数和聚合函数比较容易混淆,二者的区别主要在于聚合函数是将多条记录聚合为一条记录,窗口函数是每条记录都会执行,执行后记录条数不会变。窗口函数不仅仅是几个函数,它是一套完整的语法,函数只是该语法的一部分

复制代码
<窗口函数> OVER (PARTITION BY <用于分组的列名> ORDER BY <用于排序的列名>  ROWS BETWEEN ... AND ...)

<窗口函数> OVER (PARTITION BY <用于分组的列名> ORDER BY <用于排序的列名> RANGE BETWEEN ... AND ...)

窗口函数的位置可以放以下两种函数:

1、专用窗口函数包括:lead、lag、first_value、last_value、rank、dense_rank和row_number等
2、聚合函数,包括:sum、avg、max、min和count等

复制代码
# 查询每个部门月薪最高的两名的员工的姓名和部门名称

select `ename`, `sal`, `dname` 
from (
    select 
        `ename`, `sal`, `dno`,
        rank() over (partition by `dno` order by `sal` desc) as `rank`
    from `tb_emp`
) as `temp` natural join `tb_dept` where `rank`<=2;

# 在MySQL 8以前的版本,可以通过下面的方式来完成类似的操作

select `ename`, `sal`, `dname`
from `tb_emp` as `t1` 
natural join tb_dept 
where ( 
    select count(*) 
    from tb_emp as t2 
    where t1.dno=t2.dno and t2.sal>t1.sal )
<2 order by dno asc, sal desc;

31、第三十一天

视图

视图:将一组查询指令构成的结果集组合成可查询的数据表的对象。简单的说,视图就是虚拟的表,但与数据表不同的是,数据表是一种实体结构,而视图是一种虚拟结构,也可以将视图理解为保存在数据库中被赋予名字的 SQL 语句

使用视图可以获得以下好处:

  • 可以将实体数据表隐藏起来,让外部程序无法得知实际的数据结构,让访问者可以使用表的组成部分而不是整个表,降低数据库被攻击的风险
  • 在大多数的情况下视图是只读的(更新视图的操作通常都有诸多的限制),外部程序无法直接透过视图修改数据
  • 重用 SQL 语句,将高度复杂的查询包装在视图表中,直接访问该视图即可取出需要的数据;也可以将视图视为数据表进行连接查询
  • 视图可以返回与实体数据表不同格式的数据,在创建视图的时候可以对数据进行格式化处理

创建视图

复制代码
create view `vw_emp_simple`
as
select  `eno`,
`ename`,
`job`,
`dno`
from  `tb_emp`;

# 因为视图不包含数据,所以每次使用视图时,都必须执行查询以获得数据
# 如果使用了连接查询、嵌套查询创建了较为复杂的视图,会发现查询性能下降得很厉害
# 因此,在使用复杂的视图前,应该进行测试以确保其性能能够满足应用的需求

即使视图是一张虚拟的表,但是其中的数据也是可以更新的,不过视图的可更新性要视具体情况而定,以下类型的视图是不能更新的:

  • 使用了聚合函数(SUM、MIN、MAX、AVG、COUNT等)、DISTINCT、GROUP BY、HAVING、UNION或者UNION ALL的视图
  • SELECT中包含了子查询的视图
  • FROM子句中包含了一个不能更新的视图的视图
  • WHERE子句的子查询引用了FROM子句中的表的视图

删除视图

复制代码
drop view if exists `vw_emp_simple`;

如果希望更新视图,先使用删除命令删除视图,也可以通过create or replace view来更新视图

视图的规则和限制

  • 视图可以嵌套,可以利用从其他视图中检索的数据来构造一个新的视图。视图也可以和表一起使用
  • 创建视图时可以使用order by子句,但如果从视图中检索数据时也使用了order by,那么该视图中原先的order by会被覆盖
  • 视图无法使用索引,也不会激发触发器(实际开发中因为性能等各方面的考虑,通常不建议使用触发器)的执行

函数

函数:用来封装功能上相对独立且会被重复使用的代码,MySQL 中的函数是可以执行 SQL 语句的,通过自定义函数实现了截断超长字符串的功能

复制代码
delimiter $$

create function fn_truncate_string(
    content varchar(10000),
    max_length int unsigned
) returns varchar(10000) no sql
begin
    declare result varchar(10000) default content;
    if char_length(content) > max_length then
        set result = left(content, max_length);
        set result = concat(result, '......');
    end if;
    return result;
end $$
delimiter ;

# 在查询中调用自定义函数
select fn_truncate_string('和我在成都的街头走一走,直到所有的灯都熄灭了也不停留', 10) as short_string;

+--------------------------------------+
| short_string                         |
+--------------------------------------+
| 和我在成都的街头走一......                 
+--------------------------------------+

# 1、函数声明后面的no sql是声明函数体并没有使用 SQL 语句;如果函数体中需要通过 SQL 读取数据,需要声明为reads sql data
# 2、定义函数前后的delimiter命令是为了修改终止符(定界符),因为函数体中的语句都是用;表示结束,如果不重新定义定界符,那么遇到的;的时候代码就会被截断执行

过程

过程(又称存储过程)是事先编译好存储在数据库中的一组 SQL 的集合,调用过程可以简化应用程序开发人员的工作,减少与数据库服务器之间的通信,对于提升数据操作的性能有所帮助。迄今为止使用的 SQL 语句都是针对一个或多个表的单条语句,但在实际开发中经常会遇到某个操作需要多条 SQL 语句才能完成的情况。例如,电商网站在受理用户订单时,需要做以下一系列的处理

  • 通过查询来核对库存中是否有对应的物品以及库存是否充足
  • 如果库存有物品,需要锁定库存以确保这些物品不再卖给别人, 并且要减少可用的物品数量以反映正确的库存量
  • 如果库存不足,可能需要进一步与供应商进行交互或者至少产生一条系统提示消息
  • 不管受理订单是否成功,都需要产生流水记录,而且需要给对应的用户产生一条通知信息

可以通过过程将复杂的操作封装起来,不仅有助于保证数据的一致性,而且将来如果业务发生变动,只需要调整和修改过程即可。对于调用过程的用户来说,过程并没有暴露数据表的细节,而且执行过程比一条条的执行一组 SQL 要快得多

复制代码
# 下面的过程实现 hrs 数据库中员工工资的普调
# 具体的规则是:
# 10部门的员工薪资上浮300
# 20部门的员工薪资上浮800
# 30部门的员工薪资上浮500

delimiter $$

create procedure sp_upgrade_salary()
begin
    declare flag boolean default 1;
    -- 定义一个异常处理器
    declare continue handler for sqlexception set flag=0;

    -- 开启事务环境
    start transaction;
    
    update tb_emp set sal=sal+300 where dno=10;
    update tb_emp set sal=sal+800 where dno=20;
    update tb_emp set sal=sal+500 where dno=30;

    -- 提交或回滚事务
    if flag then
        commit;
    else
        rollback;
    end if;
end $$
delimiter ;

# 调用过程
call sp_upgrade_salary();

# 删除过程
drop procedure if exists sp_upgrade_salary;

说明:代码中使用了start transaction来开启事务环境。为了确定代码中是否发生异常,从而提交或回滚事务。此过程中定义了一个名为 flag 的变量和一个异常处理器,如果发生了异常,flag将会被赋值为0,后面的分支结构会根据flag的值来决定是执行commit,还是执行rollback

在过程中,可以定义变量、条件,可以使用分支和循环语句,可以通过游标操作查询结果,还可以使用事件调度器,但是在实际开发中,如果频繁的使用过程并将大量复杂的运算放到过程中,会给据库服务器造成巨大的压力,而数据库往往都是性能瓶颈所在。所以,一般建议让数据库只做好存储,复杂的运算和处理交给应用服务器上的程序去完成。如果应用服务器变得不堪重负了,可以比较容易的部署多台应用服务器来分摊这些压力

其他内容

范式理论

范式理论是设计关系型数据库中二维表的指导思想

  • 第一范式:数据表的每个列的值域都是由原子值组成的,不能够再分割
  • 第二范式:数据表里的所有数据都要和该数据表的键(主键与候选键)有完全依赖关系
  • 第三范式:所有非键属性都只和候选键有相关性,也就是说非键属性之间应该是独立无关的

实际工作中,出于效率的考虑,在设计表时很有可能做出反范式设计,即故意降低方式级别,增加冗余数据来获得更好的操作性能

数据完整性

实体完整性 - 每个实体都是独一无二的

  • 主键(primary key) / 唯一约束(unique)

引用完整性(参照完整性)- 关系中不允许引用不存在的实体

  • 外键(foreign key)

域(domain)完整性 - 数据是有效的

  • 数据类型及长度
  • 非空约束(not null)
  • 默认值约束(default)
  • 检查约束(check)

数据一致性

事务:一系列对数据库进行读/写的操作,这些操作要么全都成功,要么全都失败

事务的 ACID 特性

  • 原子性:事务作为一个整体被执行,对数据库的操作要么全部被执行,要么都不执行
  • 一致性:事务应确保数据库的状态从一个一致状态转变为另一个一致状态
  • 隔离性:多个事务并发执行时,一个事务的执行不应影响其他事务的执行
  • 持久性:已被提交的事务对数据库的修改应该永久保存在数据库中

MySQL 中的事务操作

  • 开启事务环境:start transaction
  • 提交事务:commit
  • 回滚事务:rollback
  • 查看事务隔离级别:show variables like 'transaction_isolation';
  • 修改(当前会话)事务隔离级别:set session transaction isolation level read committed;

关系型数据库的事务是一个很大的话题,因为当存在多个并发事务访问数据时,就有可能出现三类读数据的问题(脏读、不可重复读、幻读)和两类更新数据的问题(第一类丢失更新、第二类丢失更新)。为了避免这些问题,关系型数据库底层是有对应的锁机制的,按锁定对象不同可以分为表级锁和行级锁,按并发事务锁定关系可以分为共享锁和独占锁。然而直接使用锁是非常麻烦的,为此数据库为用户提供了自动锁机制,只要用户指定适当的事务隔离级别,数据库就会通过分析 SQL 语句,然后为事务访问的资源加上合适的锁。此外,数据库还会维护这些锁通过各种手段提高系统的性能

ANSI/ISO SQL 92标准定义了4个等级的事务隔离级别,事务隔离级别和数据访问的并发性是对立的,事务隔离级别越高并发性就越差,所以要根据具体的应用来确定到底使用哪种事务隔离级别

32、第三十二天

索引可以用来提升关系型数据库的查询性能,是比较重要的手段。创建索引虽然会带来存储空间上的开销,会占用一定的空间,但是对于换来的查询时间的减少是非常显著的

MySQL 数据库中所有数据类型的列都可以被索引。对于MySQL 8.0 版本的 InnoDB 存储引擎来说,它支持三种类型的索引,分别是 B+ 树索引、全文索引和 R 树索引。最为广泛的是 B+ 树索引,使用 B+ 树的原因非常简单,因为它是目前在基于磁盘进行海量数据存储和排序上最有效率的数据结构。B+ 树是一棵平衡树,树的高度通常为3或4,但是却可以保存从百万级到十亿级的数据,而从这些数据里面查询一条数据,只需要3次或4次 I/O 操作

B+ 树由根节点、中间节点和叶子节点构成,其中叶子节点用来保存排序后的数据。由于记录在索引上是排序过的,因此在一个叶子节点内查找数据时可以使用二分查找,这种查找方式效率非常的高。当数据很少的时候,B+ 树只有一个根节点,数据也就保存在根节点上。随着记录越来越多,B+ 树会发生分裂,根节点不再保存数据,而是提供了访问下一层节点的指针,帮助快速确定数据在哪个叶子节点上

在创建二维表时,通常都会为表指定主键列,主键列上默认会创建索引,而对于 MySQL InnoDB 存储引擎来说,因为它使用的是索引组织表这种数据存储结构,所以主键上的索引就是整张表的数据,而这种索引将其称之为聚集索引(clustered index)。很显然,一张表只能有一个聚集索引,否则表的数据就要保存多次,而自己创建的索引都是二级索引(secondary index),更常见的叫法是非聚集索引(non-clustered index)。通过自定义的非聚集索引只能定位记录的主键,在获取数据时可能需要再通过主键上的聚集索引进行查询,这种现象称为"回表",因此通过非聚集索引检索数据通常比使用聚集索引检索数据要慢

复制代码
# 要根据学生的姓名来查找学生,可以使用 MySQL 的explain关键字来查看 SQL 的执行计划

explain select * from tb_student where stuname='林震南'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: tb_student
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 11
     filtered: 10.00
        Extra: Using where
1 row in set, 1 warning (0.00 sec)

在SQL 执行计划中,有几项值得关注:

1、select_type:查询的类型

  • SIMPLE:简单 SELECT,不需要使用 UNION 操作或子查询
  • PRIMARY:如果查询包含子查询,最外层的 SELECT 被标记为 PRIMARY
  • UNION:UNION 操作中第二个或后面的 SELECT 语句
  • SUBQUERY:子查询中的第一个 SELECT
  • DERIVED:派生表的 SELECT 子查询

2、table:查询对应的表

3、type:MySQL 在表中找到满足条件的行的方式,也称为访问类型,包括:ALL(全表扫描)index(索引全扫描,只遍历索引树)、range(索引范围扫描)、ref(非唯一索引扫描)、eq_ref(唯一索引扫描)、const / system(常量级查询)、NULL(不需要访问表或索引)。在所有的访问类型中,很显然 ALL 是性能最差的,它代表的全表扫描是指要扫描表中的每一行才能找到匹配的行

4、possible_keys:MySQL 可以选择的索引,但是有可能不会使用

5、key:MySQL 真正使用的索引,如果为NULL就表示没有使用索引

6、key_len:使用的索引的长度,在不影响查询的情况下肯定是长度越短越好

7、rows:执行查询需要扫描的行数,这是一个预估值

8、extra:关于查询额外的信息

  • Using filesort:MySQL 无法利用索引完成排序操作
  • Using index:只使用索引的信息而不需要进一步查表来获取更多的信息
  • Using temporary:MySQL 需要使用临时表来存储结果集,常用于分组和排序
  • Impossible where:where子句会导致没有符合条件的行
  • Distinct:MySQL 发现第一个匹配行后,停止为当前的行组合搜索更多的行
  • Using where:查询的列未被索引覆盖,筛选条件并不是索引的前导列

当通过学生名字查询学生时实际上是进行了全表扫描,这个查询性能肯定是非常糟糕的,尤其是在表中的行很多的时候。如果需要经常通过学生姓名来查询学生,那么就应该在学生姓名对应的列上创建索引,通过索引来加速查询

复制代码
create index idx_student_name on tb_student(stuname);

explain select * from tb_student where stuname='林震南'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: tb_student
   partitions: NULL
         type: ref
possible_keys: idx_student_name
          key: idx_student_name
      key_len: 62
          ref: const
         rows: 1
     filtered: 100.00
        Extra: NULL
1 row in set, 1 warning (0.00 sec)

在对学生姓名创建索引后,查询已经不是全表扫描而是基于索引的查询,而且扫描的行只有唯一的一行,明显地提升了查询的性能。MySQL 中还允许创建前缀索引,即对索引字段的前N个字符创建索引,这样的话可以减少索引占用的空间(但节省了空间很有可能会浪费时间,时间和空间是不可调和的矛盾)

复制代码
create index idx_student_name_1 on tb_student(stuname(1));
# 相当于是根据学生姓名的第一个字来创建的索引

explain select * from tb_student where stuname='林震南'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: tb_student
   partitions: NULL
         type: ref
possible_keys: idx_student_name
          key: idx_student_name
      key_len: 5
          ref: const
         rows: 2
     filtered: 100.00
        Extra: Using where
1 row in set, 1 warning (0.00 sec)

# 这一次扫描的行变成了2行,因为学生表中有两个姓"林"的学生
# 只用姓名的第一个字作为索引的话,在查询时通过索引就会找到这两行

删除索引

复制代码
# 两种均可
alter table tb_student drop index idx_student_name;

drop index idx_student_name on tb_student;

在创建索引时,还可以使用复合索引、函数索引,用好复合索引实现索引覆盖可以减少不必要的排序和回表操作,这样就会让查询的性能成倍的提升

总结一下索引的设计原则:

  • 最适合索引的列是出现在WHERE子句和连接子句中的列
  • 索引列的基数越大(取值多、重复值少),索引的效果就越好
  • 使用前缀索引可以减少索引占用的空间,内存中可以缓存更多的索引
  • 索引不是越多越好,虽然索引加速了读操作(查询),但是写操作(增、删、改)都会变得更慢,因为数据的变化会导致索引的更新,就如同书籍章节的增删需要更新目录一样
  • 使用 InnoDB 存储引擎时,表的普通索引都会保存主键的值,所以主键要尽可能选择较短的数据类型,这样可以有效的减少索引占用的空间,提升索引的缓存效果

最后,还有一点需要说明,InnoDB 使用的 B-tree 索引,数值类型的列除了等值判断时索引会生效之外,使用>、<、>=、<=、BETWEEN...AND... 、<>时,索引仍然生效;对于字符串类型的列,如果使用不以通配符开头的模糊查询,索引也是起作用的,但是其他的情况会导致索引失效,这就意味着很有可能会做全表查询

33、第三十三天

可以使用 mysqlclient 或者 pymysql 三方库来接入 MySQL 数据库并实现数据持久化操作,不过会推荐使用纯 Python 的三方库pymysql,因为更容易安装成功

接入MySQL

首先,可以在命令行或者 PyCharm 的终端中安装pymysql,如果需要接入 MySQL 8,还需要安装cryptography 的三方库来支持 MySQL 8 的密码认证方式

复制代码
pip install pymysql cryptography

使用pymysql操作 MySQL 的步骤:

1、创建连接。MySQL 服务器启动后,提供了基于 TCP 的网络服务。可以通过 pymysql 模块的connect函数连接 MySQL 服务器。在调用connect函数时,需要指定主机、端口、用户名、口令、数据库、字符集(charset)等参数,该函数会返回一个Connection对象

2、获取游标。连接 MySQL 服务器成功后,接下来要做的就是向数据库服务器发送 SQL 语句,MySQL 会执行接收到的 SQL 并将执行结果通过网络返回。不过需要先通过连接对象的cursor方法获取游标(Cursor)对象

3、发出 SQL。通过游标对象的execute方法,可以向数据库发出 SQL 语句

4、如果执行insert、delete或update操作,需要根据实际情况提交或回滚事务。因为创建连接时,默认开启了事务环境,在操作完成后,需要使用连接对象的 commit 或 rollback 方法,实现事务的提交或回滚,rollback方法通常会放在异常捕获代码块except中。如果执行select操作,需要通过游标对象抓取查询的结果,对应的方法有三个,分别是:fetchone、fetchmany 和 fetchall。其中fetchone方法会抓取到一条记录,并以元组或字典的方式返回;fetchmany和fetchall方法会抓取到多条记录,以嵌套元组或列表装字典的方式返回

5、关闭连接。在完成持久化操作后,不要忘记关闭连接,释放外部资源。通常会在finally代码块中使用连接对象的close方法来关闭连接

复制代码
# 通过代码实操演示五个步骤
# 插入数据

import pymysql

no = int(input('部门编号: '))
name = input('部门名称: ')
location = input('部门所在地: ')

# 1. 创建连接(Connection)
conn = pymysql.connect(host='127.0.0.1', port=3306,
                       user='guest', password='Guest.618',
                       database='hrs', charset='utf8mb4')
try:
    # 2. 获取游标对象(Cursor)
    with conn.cursor() as cursor:
        # 3. 通过游标对象向数据库服务器发出SQL语句
        affected_rows = cursor.execute(
            'insert into `tb_dept` values (%s, %s, %s)',
            (no, name, location)
        )
        if affected_rows == 1:
            print('新增部门成功!!!')
    # 4. 提交事务(transaction)
    conn.commit()
except pymysql.MySQLError as err:
    # 4. 回滚事务
    conn.rollback()
    print(type(err), err)
finally:
    # 5. 关闭连接释放资源
    conn.close()

127.0.0.1称为回环地址,它代表的是本机,不建议在项目中直接使用root超级管理员账号访问数据库,因为会有风险

复制代码
# 创建名为guest的用户并为其授权

create user 'guest'@'%' identified by 'Guest.618';
grant insert, delete, update, select on `hrs`.* to 'guest'@'%';

如果要插入大量数据,建议使用游标对象的executemany方法做批处理,游标对象的executemany方法第一个参数仍然是 SQL 语句,第二个参数可以是包含多组数据的列表或元组

删除数据

复制代码
import pymysql

no = int(input('部门编号: '))

# 1. 创建连接(Connection)
conn = pymysql.connect(host='127.0.0.1', port=3306,
                       user='guest', password='Guest.618',
                       database='hrs', charset='utf8mb4',
                       autocommit=True)
try:
    # 2. 获取游标对象(Cursor)
    with conn.cursor() as cursor:
        # 3. 通过游标对象向数据库服务器发出SQL语句
        affected_rows = cursor.execute(
            'delete from `tb_dept` where `dno`=%s',
            (no, )
        )
        if affected_rows == 1:
            print('删除部门成功!!!')
finally:
    # 5. 关闭连接释放资源
    conn.close()

如果不希望每次 SQL 操作之后手动提交或回滚事务,可以connect 函数中加一个名为autocommit的参数并将它的值设置为True,表示每次执行 SQL 成功后自动提交。但是建议手动提交或回滚,这样可以根据实际业务需要来构造事务环境。如果不愿意捕获异常并进行处理,可以在try代码块后直接跟finally块,省略except意味着发生异常时,代码会直接崩溃并将异常栈显示在终端中

更新数据

复制代码
import pymysql

no = int(input('部门编号: '))
name = input('部门名称: ')
location = input('部门所在地: ')

# 1. 创建连接(Connection)
conn = pymysql.connect(host='127.0.0.1', port=3306,
                       user='guest', password='Guest.618',
                       database='hrs', charset='utf8mb4')
try:
    # 2. 获取游标对象(Cursor)
    with conn.cursor() as cursor:
        # 3. 通过游标对象向数据库服务器发出SQL语句
        affected_rows = cursor.execute(
            'update `tb_dept` set `dname`=%s, `dloc`=%s where `dno`=%s',
            (name, location, no)
        )
        if affected_rows == 1:
            print('更新部门信息成功!!!')
    # 4. 提交事务
    conn.commit()
except pymysql.MySQLError as err:
    # 4. 回滚事务
    conn.rollback()
    print(type(err), err)
finally:
    # 5. 关闭连接释放资源
    conn.close()

查询数据

1、查询部门表的数据

复制代码
import pymysql

conn = pymysql.connect(host='127.0.0.1', port=3306,
                       user='guest', password='Guest.618',
                       database='hrs', charset='utf8mb4')
try:
    with conn.cursor() as cursor:
        cursor.execute('select `dno`, `dname`, `dloc` from `tb_dept`')
        row = cursor.fetchone()
        while row:
            print(row)
            row = cursor.fetchone()
except pymysql.MySQLError as err:
    print(type(err), err)
finally:
    conn.close()

通过构造一个while循环实现了逐行抓取查询结果的操作,特别适合查询结果有非常多行的场景。因为如果使用fetchall一次性将所有记录抓取到一个嵌套元组中,会造成非常大的内存开销,对于很多场景下并不是一个好主意,如果不愿意使用while循环,还可以考虑使用iter函数构造一个迭代器来逐行抓取数据

2、分页查询员工表的数据

复制代码
import pymysql

page = int(input('页码: '))
size = int(input('大小: '))

con = pymysql.connect(host='127.0.0.1', port=3306,
                      user='guest', password='Guest.618',
                      database='hrs', charset='utf8')
try:
    with con.cursor(pymysql.cursors.DictCursor) as cursor:
        cursor.execute(
            'select `eno`, `ename`, `job`, `sal` from `tb_emp` order by `sal` desc limit %s,%s',
            ((page - 1) * size, size)
        )
        for emp_dict in cursor.fetchall():
            print(emp_dict)
finally:
    con.close()

34、第三十四天

Web应用机制和术语

少了反向代理服务器、数据库服务器、防火墙等,图中每个节点在实际项目部署时可能是一组节点组成的集群

术语 解释
URL/URI 统一资源定位符/统一资源标识符,网络资源的唯一标识
域名 与Web服务器地址对应的一个易于记忆的字符串名字
DNS 域名解析服务,可以将域名转换成对应的IP地址
IP地址 网络上的主机的身份标识,通过IP地址可以区分不同的主机
HTTP 超文本传输协议,构建在TCP之上的应用级协议,万维网数据通信的基础
反向代理 代理客户端向服务器发出请求,然后将服务器返回的资源返回给客户端
Web服务器 接受HTTP请求,然后返回HTML文件、纯文本文件、图像等资源给请求者
Nginx 高性能的Web服务器,也可以用作反向代理负载均衡HTTP缓存

HTTP协议

HTTP(超文本传输协议)是构建于TCP之上应用级协议,它利用了TCP提供的可靠的传输服务实现了Web应用中的数据交换。设计HTTP最初的目的是为了提供一种发布和接收HTML页面的方法,也就是说这个协议是浏览器和Web服务器之间传输的数据的载体

  • HTTP响应(响应行+响应头+空行+消息体)
  • HTTP请求(请求行+请求头+空行+消息体

Django概述

所谓Web框架,就是用于开发Web服务器端应用的基础设施,通俗来说就是一系列封装好的模块和工具。即便没有Web框架,仍然可以通过socket或CGI来开发Web服务器端应用,但是这样做的成本和代价很高,通常是不能接受的。通过Web框架,可以降低创建、更新、扩展应用程序的工作量,常见的框架包括Django、Flask、Tornado、Sanic、Pyramid、Bottle、Web2py、web.py等

Django无疑是最有代表性的重量级选手,可以快速地开发可靠的Web应用程序,因为它减少了Web开发中不必要的开销,对常用的设计和开发模式进行了封装,并对MVC架构提供了支持(Django中称之为MTV架构)。MVC系统中的组件分为模型(Model)、视图(View)和控制器(Controller)三个部分并借此实现模型(数据)和视图(显示)的解耦合。由于模型和视图进行了分离,所以需要控制器(中间人)将解耦合的模型和视图联系起来。Django中MTV的M也代表数据的模型,T代表了网页模板(显示数据的视图),而V代表了视图函数,视图函数和Django框架本身一起扮演了MVC中C的角色

"模型" 说得更直白一些就是数据(的表示),通常也被称作"数据模型"。在实际的项目中,数据模型通常通过数据库实现持久化操作,而关系型数据库在过去和当下都是持久化的首选方案

Django模型最佳实践

  • 正确的为模型和关系字段命名
  • 设置适当的related_name属性
  • 用OneToOneField代替ForeignKeyField(unique=True)
  • 通过"迁移操作"(migrate)来添加模型
  • 用NoSQL来应对需要降低范式级别的场景
  • 如果布尔类型可以为空要使用NullBooleanField
  • 在模型中放置业务逻辑
  • 用<ModelName>.DoesNotExists取代ObjectDoesNotExists
  • 在数据库中不要出现无效数据
  • 不要对QuerySet调用len()函数
  • 将QuerySet的exists()方法的返回值用于if条件
  • 用DecimalField来存储货币相关数据而不是FloatField
  • 定义__str__方法
  • 不要将数据文件放在同一个目录中

35、第三十五天

加载静态资源

静态资源是指网页运行过程中通常不需要动态生成的文件,例如:

  • CSS 样式文件
  • JavaScript 脚本
  • 图片、字体和图标

开发时,可以建立统一的静态资源目录,并按照资源类型划分子目录,例如:

复制代码
static/
├── css/
├── js/
└── images/

在 Django 配置文件中,需要声明静态资源目录及其访问路径:

复制代码
STATICFILES_DIRS = [os.path.join(BASE_DIR, "static"),]
STATIC_URL = "/static/"

在模板中,推荐使用 Django 提供的静态资源标签,而不是直接写死路径:

复制代码
{% load static %}
<img src="{% static 'images/example.png' %}" alt="示例图片">

在项目正式部署到线上环境后,通常会把静态资源交给专门的静态资源服务器(如Nginx、Apache)来处理,而不是有运行Python代码的服务器来管理静态资源,所以上面的配置并不适用于生产环境,仅供项目开发阶段测试使用

Ajax概述

Ajax 是"Asynchronous JavaScript and XML"的缩写,即异步 JavaScript 和 XML。它的核心作用是:浏览器可以在不刷新整个页面的情况下与服务器交换数据,然后只更新页面中的局部内容

对于传统的Web应用,每次页面上需要加载新的内容都需要重新请求服务器并刷新整个页面,如果服务器短时间内无法给予响应或者网络状况并不理想,那么可能会造成浏览器长时间的空白并使得用户处于等待状态,在这个期间用户什么也做不了,因此并不能带来很好的用户体验

复制代码
用户操作 → 请求服务器 → 重新加载整个页面 → 显示新内容

对于使用Ajax技术的Web应用,浏览器可以向服务器发起异步请求来获取数据。异步请求不会中断用户体验,当服务器返回了新的数据,可以通过JavaScript代码进行DOM操作来实现对页面的局部刷新,相当于在不刷新整个页面的情况下更新了页面的内容。异步请求通常不会阻塞当前页面,用户在等待服务器响应时仍然可以继续进行其他操作,因此能够改善交互体验

复制代码
用户操作 → 异步请求服务器 → 接收数据 → 更新部分页面内容

在使用Ajax技术时,浏览器跟服务器通常会交换XML或JSON格式的数据,XML是以前使用得非常多的一种数据格式,近年来几乎已经完全被JSON取代。相较于XML,JSON通常具有以下优点:

  • 格式更简洁
  • 数据体积更小
  • 便于JavaScript处理
  • 更适合作为Web API的数据交换格式

36、第三十六天

MD5 消息摘要算法

MD5 消息摘要算法是一种被广泛使用的密码哈希函数,可以产生出一个128位 的哈希值(散列值),用于确保信息传输完整一致。在使用哈希值时,通常会将哈希值表示为16进制字符串,因此128位的MD5摘要通常表示为32个十六进制符号。由于通常不能将用户的密码直接保存在数据库中,因此需要将用户密码处理成对应的MD5摘要

CSRF

模板指令 {% csrf_token %} 可以为表单添加一个隐藏域(可以在浏览器中显示网页源代码就可以看到这个指令生成的 type 属性为 hidden 的 input 标签),它的作用是在表单中生成一个随机令牌(token)来防范跨站请求伪造(简称为CSRF),这也是Django在提交表单时的硬性要求。如果表单中没有这样的令牌,那么提交表单时,Django框架会产生一个响应状态码为403的响应(禁止访问),除非设置了免除CSRF令牌

对一个Web应用来说,用户登录成功后必然要让服务器能够记住该用户已经登录,这样才能为用户提供更好的服务,而CSRF是通过钓鱼网站来套取用户登录信息进行恶意操作的攻击手段,这些都是以用户跟踪技术为基础的

实现用户跟踪

如果一个网站不能通过某种方式记住用户以及用户之前在网站的活动情况,将会失去网站的可用性和便利性,继而很有可能导致网站用户的流失,所以记住一个用户(用户跟踪)对绝大多数Web应用来说都是必需的功能

在服务器端,记住一个用户最简单的办法就是创建一个对象,通过这个对象就可以把用户相关的信息都保存起来,这个对象就是常说的session(用户会话对象)。但HTTP本身是一个无连接 (每次请求和响应的过程中,服务器一旦完成对客户端请求的响应之后就断开连接)、无状态(客户端再次发起对服务器的请求时,服务器无法得知这个客户端之前的任何信息)的协议,即便服务器通过session对象保留了用户数据,还得通过某种方式来确定当前的请求与之前保存过的哪一个session是有关联的。可以给每个session对象分配一个全局唯一的标识符来识别session对象,称之为sessionid,每次客户端发起请求时,只要携带上这个sessionid,就有办法找到对应的session对象,从而实现在两次请求之间记住该用户的信息

客户端记住并在每次请求时带上sessionid 有以下几种做法:

1、URL重写:就是在URL中携带sessionid,例如:http://www.example.com/index.html?sessionid=123456,服务器通过获取sessionid参数的值来取到与之对应的session对象

2、隐藏域(隐式表单域):在提交表单的时候,可以通过在表单中设置隐藏域向服务器发送额外的数据。例如:<input type="hidden" name="sessionid" value="123456">

3、本地存储:现在的浏览器都支持多种本地存储方案,包括:cookie、localStorage、sessionStorage、IndexedDB等。cookie是一种以键值对方式保存在浏览器临时文件中的数据,每次请求时,请求头中会携带本站点的cookie到服务器,那么只要将sessionid写入cookie,下次请求时服务器只要读取请求头中的cookie就能够获得这个sessionid

除了cookie,还可以使用新的本地存储API来保存数据:localStorage、sessionStorage、IndexedDB等技术

实现用户跟踪,服务器端可以为每个用户会话创建一个session对象并将session对象的ID写入到浏览器的cookie中;用户下次请求服务器时,浏览器会在HTTP请求头中携带该网站保存的cookie信息,这样服务器就可以从cookie中找到session对象的ID并根据此ID获取到之前创建的session对象;由于session对象可以用键值对的方式保存用户数据,这样之前保存在session对象中的信息可以悉数取出,服务器也可以根据这些信息判定用户身份和了解用户偏好,为用户提供更好的个性化服务

Django框架对session的支持

在创建Django项目时,默认的配置文件settings.py文件中已经激活了一个名为SessionMiddleware的中间件,因为中间件的存在,可以直接通过请求对象的session属性来操作会话对象。session属性是像字典一样可以读写数据的容器对象,因此可以使用"键值对"的方式来保留用户数据。与此同时,SessionMiddleware中间件还封装了对cookie的操作,可以在cookie中保存了sessionid

在默认情况下,Django将session的数据序列化后保存在关系型数据库中,在Django 1.6以后的版本中,默认的序列化数据的方式是JSON序列化,而在此之前一直使用Pickle序列化。JSON序列化和Pickle序列化的差别在于前者将对象序列化为字符串(字符形式),而后者将对象序列化为字节串(二进制形式),因为安全方面的原因,JSON序列化成为了目前Django框架默认序列化数据的方式,这就要求保存在session中的数据必须是能够JSON序列化的,否则就会引发异常。还有一点就是使用关系型数据库保存session中的数据在大多数时候并不是最好的选择,因为数据库可能会承受巨大的压力而成为系统性能的瓶颈

在视图函数中读写cookie

Django封装的HttpRequest和HttpResponse对象分别提供了读写cookie的操作

HttpRequest封装的属性和方法:

  • COOKIES属性:该属性包含了HTTP请求携带的所有cookie
  • get_signed_cookie方法:获取带签名的cookie,如果签名验证失败会产生BadSignature异常

HttpResponse封装的方法:

  • set_cookie方法:该方法可以设置一组键值对并将其最终将写入浏览器
  • set_signed_cookie方法:跟上面的方法作用相似,但是会对cookie进行签名来达到防篡改的作用。因为如果篡改了cookie中的数据, 在不知道密钥(在Django项目配置文件中指定的SECRET_KEY)和盐(程序中设定的一个字符串,无论是都可以,但必须是一个有效的字符串)的情况下是无法生成有效的签名,服务器在读取cookie时会发现数据与签名不一致从而产生BadSignature异常

在激活SessionMiddleware之后,每个HttpRequest对象都会绑定一个session属性,它是一个类似字典的对象,除了保存用户数据之外还提供了检测浏览器是否支持cookie的方法,包括:

  • set_test_cookie方法:设置用于测试的cookie
  • test_cookie_worked方法:检测测试cookie是否工作
  • delete_test_cookie方法:删除用于测试的cookie
  • set_expiry方法:设置会话的过期时间
  • get_expire_age/get_expire_date方法:获取会话的过期时间
  • clear_expired方法:清理过期的会话

通常情况下,浏览器默认开启了对cookie的支持,但是可能因为某种原因,用户禁用了浏览器的cookie功能,遇到这种情况可以在视图函数中提供一个检查功能,如果检查到用户浏览器不支持cookie,可以给出相应的提示

复制代码
def login(request):
    if request.method == 'POST':
        if request.session.test_cookie_worked():
            request.session.delete_test_cookie()
            # Add your code to perform login process here
        else:
            return HttpResponse("Please enable cookies and try again.")
    request.session.set_test_cookie()
    return render_to_response('login.html')

Cookie的替代品

在实际开发中是不会在cookie中保存用户的敏感信息(如用户的密码、信用卡的账号等)的,而且保存在cookie中的数据一般也会做好编码和签名的工作。对于支持HTML5的浏览器来说,可以使用localStorage和sessionStorage做为cookie的替代方案,存储在localStorage的数据可以长期保留;而存储在sessionStorage的数据会在浏览器关闭时会被清除

37、第三十七天

导出Excel报表

报表就是用表格、图表等格式来动态显示数据,所以可以用这样的公式来描述报表:

复制代码
报表 = 多样的格式 + 动态的数据

有很多的三方库支持在Python程序中写Excel文件,包括xlwt、xlwings、openpyxl、xlswriter等,其中的xlwt虽然只支持写xls格式的Excel文件,但在性能方面的表现还是不错的

复制代码
pip install xlwt

导出PDF报表

在Django项目中,如果需要导出PDF报表,可以借助三方库reportlab来生成PDF文件的内容,再将文件的二进制数据输出给浏览器并指定MIME类型为 application/pdf

生成前端统计图表

如果项目中需要生成前端统计图表,可以使用百度的ECharts。具体是后端通过提供数据接口返回统计图表所需的数据,前端使用ECharts来渲染出柱状图、折线图、饼图、散点图等图表

38、第三十八天

配置日志

项目开发阶段,对于开发人员来说,显示足够的调试信息去调试代码还是非常必要的;项目上线以后,将系统运行时出现的警告、错误等信息记录下来以备开发人员了解系统运行状况并维护代码也是很有必要的。与此同时,采集日志数据也是为网站做数字化运营奠定一个基础,通过对系统运行日志的分析,可以监测网站的流量以及流量分布,同时还可以挖掘出用户的使用习惯和行为模式

formatters是日志格式化器,它代表了如何格式化输出日志,其中格式占位符分别表示:

  • %(name)s :记录器的名称
  • %(levelno)s:数字形式的日志记录级别
  • %(levelname)s:日志记录级别的文本名称
  • %(filename)s:执行日志记录调用的源文件的文件名称
  • %(pathname)s:执行日志记录调用的源文件的路径名称
  • %(funcName)s:执行日志记录调用的函数名称
  • %(module)s:执行日志记录调用的模块名称
  • %(lineno)s:执行日志记录调用的行号
  • %(created)s:执行日志记录的时间
  • %(asctime)s:日期和时间
  • %(msecs)s:毫秒部分
  • %(thread)d:线程ID(整数)
  • %(threadName)s:线程名称
  • %(process)d:进程ID (整数)

日志配置中的handlers用来指定日志处理器,就是指定将日志输出到控制台还是文件又或者是网络上的服务器,可用的处理器包括:

  1. logging.StreamHandler(stream=None):可以向类似与sys.stdout或者sys.stderr的任何文件对象输出信息
  2. logging.FileHandler(filename, mode='a', encoding=None, delay=False):将日志消息写入文件
  3. logging.handlers.DatagramHandler(host, port):使用UDP协议,将日志信息发送到指定主机和端口的网络主机上
  4. logging.handlers.HTTPHandler(host, url):使用HTTP的GET或POST方法将日志消息上传到一台HTTP 服务器
  5. logging.handlers.RotatingFileHandler(filename, mode='a', maxBytes=0, backupCount=0, encoding=None, delay=False):将日志消息写入文件,如果文件的大小超出maxBytes指定的值,那么将重新生成一个文件来记录日志
  6. logging.handlers.SocketHandler(host, port):使用TCP协议,将日志信息发送到指定主机和端口的网络主机上
  7. logging.handlers.SMTPHandler(mailhost, fromaddr, toaddrs, subject, credentials=None, secure=None, timeout=1.0):将日志输出到指定的邮件地址
  8. logging.MemoryHandler(capacity, flushLevel=ERROR, target=None, flushOnClose=True):将日志输出到内存指定的缓冲区中

每个日志处理器都指定了一个名为level的属性,它代表了日志的级别,不同的日志级别反映出日志中记录信息的严重性。Python中定义了六个级别的日志,按照从低到高的顺序依次是:NOTSET、DEBUG、INFO、WARNING、ERROR、CRITICAL

最后配置的日志记录器是用来真正输出日志的,Django框架提供了如下所示的内置记录器:

  1. django:在Django层次结构中的所有消息记录器
  2. django.request:与请求处理相关的日志消息。5xx响应被视为错误消息;4xx响应被视为为警告消息
  3. django.server:与通过runserver调用的服务器所接收的请求相关的日志消息。5xx响应被视为错误消息;4xx响应被记录为警告消息;其他一切都被记录为INFO
  4. django.template:与模板渲染相关的日志消息
  5. django.db.backends:有与数据库交互产生的日志消息,如果希望显示ORM框架执行的SQL语句,就可以使用该日志记录器

日志记录器中配置的日志级别有可能不是最终的日志级别,因为还要参考日志处理器中配置的日志级别,取二者中级别较高者作为最终的日志级别

39、第三十九天

如果应用中有很多功能都需要用户先登录才能执行,例如导出Excel报表和查看统计图表的功能都做了必须登录才能访问的限制,这种情况下肯定是不可能在每个视图函数中添加代码来检查session中是否包含userid的代码的,因为视图函数中必然会充斥着大量的重复代码。在Python程序中,可以通过装饰器来为函数提供额外的能力;在Django项目中,可以把类似于验证用户是否登录这样的重复性代码放到中间件中

Django中间件概述

中间件是安插在Web应用请求和响应过程之间的组件,它在整个Web应用中扮演了拦截过滤器的角色,通过中间件可以拦截请求和响应,并对请求和响应进行过滤(简单的说就是执行额外的处理)。通常,一个中间件组件只专注于完成一件特定的事,例如:Django框架通过SessionMiddleware中间件实现了对session的支持,又通过AuthenticationMiddleware中间件实现了基于session的请求认证。通过把多个中间件组合在一起,可以完成更为复杂的任务,Django框架就是这么做的

复制代码
# Django项目的配置文件中就包含了对中间件的配置

MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

1、CommonMiddleware:基础设置中间件,可以处理以下一些配置参数

  • DISALLOWED_USER_AGENTS:不被允许的用户代理(浏览器)
  • APPEND_SLASH:是否追加/
  • USE_ETAG:浏览器缓存相关

2、SecurityMiddleware:安全相关中间件,可以处理和安全相关的配置项

  • SECURE_HSTS_SECONDS:强制使用HTTPS的时间
  • SECURE_HSTS_INCLUDE_SUBDOMAINS:HTTPS是否覆盖子域名
  • SECURE_CONTENT_TYPE_NOSNIFF:是否允许浏览器推断内容类型
  • SECURE_BROWSER_XSS_FILTER:是否启用跨站脚本攻击过滤器
  • SECURE_SSL_REDIRECT:是否重定向到HTTPS连接
  • SECURE_REDIRECT_EXEMPT:免除重定向到HTTPS

3、SessionMiddleware:会话中间件

4、CsrfViewMiddleware:通过生成令牌,防范跨请求份伪的造中间件

5、XFrameOptionsMiddleware:通过设置请求头参数,防范点击劫持攻击的中间件

在请求的过程中,上面的中间件会按照书写的顺序从上到下执行,然后是URL解析,最后请求才会来到视图函数;在响应的过程中,上面的中间件会按照书写的顺序从下到上执行,与请求时中间件执行的顺序正好相反

自定义中间件

Django中的中间件有两种实现方式:基于类的实现方式基于函数的实现方式,后者更接近于装饰器的写法。装饰器实际上是代理模式的应用,将横切关注功能(与正常业务逻辑没有必然联系的功能,例如:身份认证、日志记录、编码转换之类的功能)置于代理中,由代理对象来完成被代理对象的行为并添加额外的功能。中间件对用户请求和响应进行拦截过滤并增加额外的处理,这一点上与装饰器完全一致,所以基于函数的写法来实现中间件就跟装饰器的写法几乎一模一样

复制代码
# middlewares.py
from django.http import JsonResponse
from django.shortcuts import redirect

# 需要登录才能访问的资源路径
LOGIN_REQUIRED_URLS = {'/praise/', '/criticize/', '/excel/', '/teachers_data/'}

def check_login_middleware(get_resp):
    def wrapper(request, *args, **kwargs):
        # 请求的资源路径在上面的集合中
        if request.path in LOGIN_REQUIRED_URLS:
            # 会话中包含userid则视为已经登录
            if 'userid' not in request.session:
                # 判断是不是Ajax请求
                if request.is_ajax():
                    # Ajax请求返回JSON数据提示用户登录
                    return JsonResponse({'code': 10003, 'hint': '请先登录'})
                else:
                    backurl = request.get_full_path()
                    # 非Ajax请求直接重定向到登录页
                    return redirect(f'/login/?backurl={backurl}')
        return get_resp(request, *args, **kwargs)

    return wrapper

当然,也可以定义一个类来充当装饰器,如果类中有__call__ 魔术方法,这个类的对象就像函数一样可调用

复制代码
from django.utils.deprecation import MiddlewareMixin

class MyMiddleware(MiddlewareMixin):
    def process_request(self, request):
        pass
    def process_view(self, request, view_func, view_args, view_kwargs):
        pass
    def process_template_response(self, request, response):
        pass
    def process_response(self, request, response):
        pass
    def process_exception(self, request, exception):
        pass

这五个方法都是中间件的钩子函数,分别在收到用户请求、进入视图函数之前、渲染模板、返回响应和出现异常的时候被回调。不过这些方法是根据中间件的需求来确定的,并不是所有的场景都需要重写五个方法

写好中间件代码后,需要修改配置文件来激活中间件使其生效

复制代码
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'debug_toolbar.middleware.DebugToolbarMiddleware',
    'vote.middlewares.check_login_middleware',
]

要注意中间件列表中元素的顺序,当收到来自用户的请求时,中间件按照从上到下的顺序依次执行,这行完这些中间件以后,请求才会最终到达视图函数。在这个过程中,用户的请求可以被拦截,就像自定义的中间件那样,如果用户在没有登录的情况下访问了受保护的资源,中间件会将请求直接重定向到登录页,后面的中间件和视图函数将不再执行。在响应用户请求的过程中,上面的中间件会按照从下到上的顺序依次执行,这样的话还可以对响应做进一步的处理

40、第四十天

在传统的Web应用开发中,会将浏览器作为前后端的分界线。将浏览器中为用户进行页面展示的部分称之为前端,而将运行在服务器为前端提供业务逻辑和数据准备的所有代码统称为后端。所谓前后端分离的开发,就是约定好数据交互接口,并行的进行开发和测试,后端只提供数据,不负责将数据渲染到页面上,前端通过HTTP请求获取数据并负责将数据渲染到页面上,这个工作是交给浏览器中的JavaScript代码来完成

使用前后端分离开发有诸多的好处:

1、提升开发效率:前后端分离以后,可以实现前后端代码的解耦,只要提前约定好应用所需接口以及接口参数,便可以并行开发,只需要专注于负责部分的开发工作即可。及时需求发生变更,只要接口与数据格式不变,后端开发人员就不需要修改代码,只要前端进行变动即可

2、增强代码的可维护性:前后端分离后,应用的代码不再是前后端混合,只有在运行期才会有调用依赖关系,因此维护代码的工作将轻松很多。当代码变得简明且整洁时,代码的可读性和可维护性都会有质的提升

3、支持多终端和服务化架构:前后端分离后,同一套数据接口可以为不同的终端提供服务,更有助于打造多终端应用;此外,后端提供的接口之间可以通过HTTP(S)进行调用,有助于打造服务化架构(包括微服务)

前后端分离的开发需要将前端页面作为静态资源进行部署,项目实际上线的时候,对整个Web应用进行动静分离,静态资源通过Nginx或Apache服务器进行部署,生成动态内容的Python程序部署在uWSGI或者Gunicorn服务器上,对动态内容的请求由Nginx或Apache路由到uWSGI或Gunicorn服务器上

41、第四十一天

把软件(Software)、平台(Platform)、基础设施(Infrastructure)做成服务(Service)就是经常听到的SasS(软件即服务)、PasS(平台即服务)和IasS(基础设置即服务)。实现面向服务的架构(SOA)有诸多的方式,包括RPC(远程过程调用)、Web Service、REST等,在技术层面上,SOA是一种抽象的、松散耦合的粗粒度软件架构;在业务层面上,SOA的核心概念是"重用"和"互操作",它将系统资源整合成可操作的、标准的服务,使得这些资源能够被重新组合和应用。在实现SOA的诸多方案中,REST被认为是最适合互联网应用的架构,符合REST规范的架构也经常被称作RESTful架构

REST概述

REST(REpresentational State Transfer的缩写,一种互联网软件的架构原则),通常翻译为"表现层状态转移"或"表述状态转移"。这里的"表现层 "其实指的是"资源"的" 表现层 ",所谓资源,就是网络上的一个实体,或者说是网络上的一个具体信息。它可以是一段文本、一张图片、一首歌曲或一种服务。可以用一个URL指向资源,要获取到这个资源,访问它的URL即可,URI就是资源在互联网上的唯一标识。资源可以有多种外在表现形式,把资源具体呈现出来的形式,叫做它的"表现层"。比如,文本可以用text/plain格式表现,也可以用text/html格式、text/xml格式、application/json格式表现,甚至可以采用二进制格式;图片可以用image/jpeg格式表现,也可以用image/png格式表现。URI只代表资源的实体,不代表它的表现形式,其实有些网址最后的.html后缀名是不必要的,因为这个后缀名表示格式,属于"表现层"范畴,而URI应该只代表"资源"的位置,它的具体表现形式,应该在HTTP请求的头信息中用 Accept 和 Content-Type字段指定,这两个字段才是对"表现层"的描述

访问一个网站,就代表了客户端和服务器的一个互动过程,而在这个过程中,一定会涉及到数据和状态的变化。Web应用通常使用HTTP作为其通信协议,客户端想要操作服务器,必须通过HTTP请求,让服务器端发生"状态转移",而这种转移是建立在表现层之上的,所以就是"表现层状态转移"。客户端通过HTTP的动词GET、POST、PUT(或PATCH)、DELETE,分别对应对资源的四种基本操作,其中GET用来获取资源,POST用来新建资源(也可以用于更新资源),PUT(或PATCH)用来更新资源,DELETE用来删除资源

简单的说RESTful架构就是:"每一个URL代表一种资源,客户端通过四个HTTP动词,对服务器端资源进行操作,实现资源的表现层状态转移"。不过,真正的RESTful架构并不只是URI符合REST风格,更为重要的是"无状态"和"幂等性"

请求方法(HTTP动词) URI 解释
GET /students/ 获取所有学生
POST /students/ 新建一个学生
GET /students/ID/ 获取指定ID的学生信息
PUT /students/ID/ 更新指定ID的学生信息(提供该学生的全部信息)
PATCH /students/ID/ 更新指定ID的学生信息(提供该学生的部分信息)
DELETE /students/ID/ 删除指定ID的学生信息
GET /students/ID/friends/ 列出指定ID的学生的所有朋友
DELETE /students/ID/friends/ID/ 删除指定ID的学生的指定ID的朋友

编写序列化器

前后端分离的开发需要后端为前端、移动端提供API数据接口,而API接口通常情况下都是返回JSON格式的数据,因此需要对模型对象进行序列化处理。DRF中封装了 Serializer 类和ModelSerializer 类用于实现序列化操作,通过继承Serializer 类或ModelSerializer 类,可以自定义序列化器,用于将对象处理成字典

复制代码
from rest_framework import serializers 

class SubjectSerializer(serializers.ModelSerializer):

    class Meta:
        model = Subject
        fields = '__all__'

# 直接继承了ModelSerializer
# 通过Meta类的model属性指定要序列化的模型以及fields属性指定需要序列化的模型字段

编写视图函数

DRF框架支持两种实现数据接口的方式,一种是FBV(基于函数的视图),另一种是CBV(基于类的视图)

复制代码
from rest_framework.decorators import api_view
from rest_framework.response import Response

@api_view(('GET', ))
def show_subjects(request: HttpRequest) -> HttpResponse:
    subjects = Subject.objects.all().order_by('no')
    # 创建序列化器对象并指定要序列化的模型
    serializer = SubjectSerializer(subjects, many=True)
    # 通过序列化器的data属性获得模型对应的字典并通过创建Response对象返回JSON格式的数据
    return Response(serializer.data)

# 相较于使用bpmapper实现模型序列化,使用DRF的代码更加简单明了
# DRF本身自带了一套页面,可以方便查看使用DRF定制的数据接口

前后端分离下的用户登录

HTTP是无状态的,一次请求结束连接断开,下次服务器再收到请求,则不清楚这个请求是哪个用户发过来的。但是对于一个Web应用而言,它是需要有状态管理的,这样才能让服务器知道HTTP请求来自哪个用户,从而判断是否允许该用户请求以及为用户提供更好的服务,这个过程就是常说的会话管理

之前做会话管理(用户跟踪)的方法是:用户登录成功后,在服务器端通过一个session对象保存用户相关数据,然后把session对象的ID写入浏览器的cookie中;下一次请求时,HTTP请求头中携带cookie的数据,服务器从HTTP请求头读取cookie中的sessionid,根据这个标识符找到对应的session对象,这样就能够获取到之前保存在session中的用户数据。因为REST架构是最适合互联网应用的架构,它强调了HTTP的无状态性,这样才能保证应用的水平扩展能力(当并发访问量增加时,可以通过增加新的服务器节点来为系统扩容)。显然,基于session实现用户跟踪的方式需要服务器保存session对象,在做水平扩展增加新的服务器节点时,需要复制和同步session对象,这显然是非常麻烦的。解决这个问题有两种方案,一种是架设缓存服务器(如Redis),让多个服务器节点共享缓存服务并将session对象直接置于缓存服务器中;另一种方式放弃基于session的用户跟踪,使用基于token的用户跟踪

基于token的用户跟踪是在用户登录成功后,为用户生成身份标识并保存在浏览器本地存储(localStorage、sessionStorage、cookie等)中,这样的话服务器不需要保存用户状态,从而可以很容易的做到水平扩展。基于token的用户跟踪具体流程如下:

  1. 用户登录时,如果登录成功就按照某种方式为用户生成一个令牌(token),该令牌中通常包含了用户标识、过期时间等信息而且需要加密并生成指纹(避免伪造或篡改令牌),服务器将令牌返回给前端
  2. 前端获取到服务器返回的token,保存在浏览器本地存储中(可以保存在localStorage或sessionStorage中,对于Vue.js的项目来说,还可以通过Vuex进行状态管理)
  3. 对于使用了前端路由的项目来说,前端每次路由跳转,可以先判断localStroage中有无token,如果没有则跳转到登录页
  4. 每次请求后端数据接口,在HTTP请求头里携带token;后端接口判断请求头有无token,如果没有token以及token是无效的或过期的,服务器统一返回401
  5. 如果前端收到HTTP响应状态码401,则重定向到登录页面

通过上面的描述,其实基于token的用户跟踪最为关键是在用户登录成功时,要为用户生成一个token作为用户的身份标识。生成token的方法比较成熟的解决方案是使用JSON Web Token

JWT概述

JSON Web Token 通常简称为JWT,它是一种开放标准。随着RESTful架构的流行,越来越多的项目使用JWT作为用户身份认证的方式。JWT相当于是三个JSON对象经过编码后,用.分隔并组合到一起,这三个JSON对象分别是头部(header)、载荷(payload)和签名(signature)

1、头部

复制代码
{
  "alg": "HS256",
  "typ": "JWT"
}

其中,alg 属性表示签名的算法,默认是HMAC SHA256(简写成HS256);typ属性表示这个令牌的类型,JWT中都统一书写为JWT

2、载荷

载荷部分用来存放实际需要传递的数据,JWT官方文档中规定了7个可选的字段:

  • iss :签发人
  • exp:过期时间
  • sub:主题
  • aud:受众
  • nbf:生效时间
  • iat:签发时间
  • jti:编号

除了官方定义的字典,可以根据应用的需要添加自定义的字段

复制代码
{
  "sub": "1234567890",
  "nickname": "jackfrued",
  "role": "admin"
}

3、签名

签名部分是对头部和载荷部分生成一个指纹,防止数据伪造和篡改。实现签名首先需要指定一个密钥,这个密钥只有服务器才知道,不能泄露给用户。然后,使用头部指定的签名算法(默认是HS256),按照下面的公式产生签名

复制代码
HS256(base64Encode(header) + '.' + base64Encode(payload), secret)

算出签名以后,把头部、载荷、签名三个部分拼接成一个字符串,每个部分用**.**进行分隔,这样就完成一个JWT

JWT的优缺点

使用JWT的优点非常明显,包括:

  • 更容易实现水平扩展,因为令牌保存在浏览器中,服务器不需要做状态管理
  • 更容易防范CSRF攻击,因为在请求头中添加localStorage或sessionStorage中的token必须靠JavaScript代码完成,而不是自动添加到请求头中的
  • 可以防伪造和篡改,因为JWT有签名,伪造和篡改的令牌无法通过签名验证,会被认定是无效的令牌

JWT也有诸多缺点,具体包括:

  • 可能会遭受到XSS攻击,通过注入恶意脚本执行JavaScript代码获取到用户令牌
  • 在令牌过期之前,无法作废已经颁发的令牌,要解决这个问题,还需要额外的中间层和代码来辅助
  • JWT是用户的身份令牌,一旦泄露,任何人都可以获得该用户的所有权限。为了降低令牌被盗用后产生的风险,JWT的有效期应该设置得比较短。对于一些比较重要的权限,使用时应通过其他方式再次对用户进行认证,例如短信验证码等

使用PyJWT

复制代码
# 在Python代码中,可以使用三方库PyJWT生成和验证JWT

# 下面是安装PyJWT的命令

pip install pyjwt

# 生成令牌

payload = {
    'exp': datetime.datetime.utcnow() + datetime.timedelta(days=1),
    'userid': 10001
}
token = jwt.encode(payload, settings.SECRET_KEY).decode()

# 验证令牌

try:
    token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1OTQ4NzIzOTEsInVzZXJpZCI6MTAwMDF9.FM-bNxemWLqQQBIsRVvc4gq71y42I9m2zt5nlFxNHUo'
    payload = jwt.decode(token, settings.SECRET_KEY)
except InvalidTokenError:
    raise AuthenticationFailed('无效的令牌或令牌已经过期')

除了使用DRF创建REST风格的数据接口之外,也可以通过CBV(基于类的视图)的方式。使用CBV创建数据接口的特点是代码简单,开发效率高,但是没有FBV(基于函数的视图)灵活,因为使用FBV的方式,数据接口对应的视图函数执行的代码以及返回的数据都是高度可定制的

42、第四十二天

Web应用的性能瓶颈通常都会出现在关系型数据库上,当并发访问量较大时,如果所有的请求都需要通过关系型数据库完成数据持久化操作,那么数据库一定会不堪重负。优化Web应用性能最为重要的一点就是使用缓存,把那些数据体量不大但访问频率非常高的数据提前加载到缓存服务器中,这是典型的空间换时间的方法。缓存服务器通常都是直接将数据置于内存中而且使用了非常高效的数据存取策略(哈希存储、键值对方式等),在读写性能上远远优于关系型数据库的,因此可以让Web应用接入缓存服务器来优化其性能,其中Redis是一个非常好的选择

Django项目接入Redis

复制代码
# 安装django-redis
pip install django-redis

修改Django配置文件中关于缓存的配置

复制代码
CACHES = {
    'default': {
        # 指定通过django-redis接入Redis服务
        'BACKEND': 'django_redis.cache.RedisCache',
        # Redis服务器的URL
        'LOCATION': ['redis://1.2.3.4:6379/0', ],
        # Redis中键的前缀(解决命名冲突)
        'KEY_PREFIX': 'vote',
        # 其他的配置选项
        'OPTIONS': {
            'CLIENT_CLASS': 'django_redis.client.DefaultClient',
            # 连接池(预置若干备用的Redis连接)参数
            'CONNECTION_POOL_KWARGS': {
                # 最大连接数
                'max_connections': 512,
            },
            # 连接Redis的用户口令
            'PASSWORD': 'foobared',
        }
    },
}

为视图提供缓存服务

声明式缓存

所谓声明式缓存:不修改原来的代码,只通过Python中的装饰器为原有的代码增加缓存功能

复制代码
# FBV

from django.views.decorators.cache import cache_page

@api_view(('GET', ))
@cache_page(timeout=86400, cache='default')
def show_subjects(request):
    """获取学科数据"""
    queryset = Subject.objects.all()
    data = SubjectSerializer(queryset, many=True).data
    return Response({'code': 20000, 'subjects': data})

通过Django封装的cache_page装饰器缓存了视图函数的返回值(响应对象),cache_page的本意是缓存视图函数渲染的页面,对于返回JSON数据的视图函数,相当于是缓存了JSON数据。在使用cache_page装饰器时,可以传入timeout参数来指定缓存过期时间,还可以使用cache参数来指定需要使用哪一组缓存服务来缓存数据。Django项目允许在配置文件中配置多组缓存服务,cache='default'指定了使用默认的缓存服务。视图函数的返回值会被序列化成字节串放到Redis中(Redis中的str类型可以接收字节串),缓存数据的序列化和反序列化也不需要自己处理,因为cache_page装饰器会调用django-redis库中的RedisCache来对接Redis,该类使用了DefaultClient来连接Redis并使用了pickle序列化,django_redis.serializers.pickle.PickleSerializer是默认的序列化类

如果缓存中没有学科的数据,那么通过接口访问学科数据时,视图函数会通过执行Subject.objects.all() 向数据库发出SQL语句来获得数据,视图函数的返回值会被缓存,因此下次请求该视图函数如果缓存没有过期,可以直接从缓存中获取视图函数的返回值,无需再次查询数据库。如果想了解缓存的使用情况,可以配置数据库日志或者使用Django-Debug-Toolbar来查看,第一次访问学科数据接口时会看到查询学科数据的SQL语句,再次获取学科数据时,不会再向数据库发出SQL语句,因为可以直接从缓存中获取数据

对于CBV,可以利用Django中名为 method_decorator 的装饰器将cache_page这个装饰函数的装饰器放到类中的方法上,效果跟上面的代码是一样的。不过cache_page装饰器不能直接放在类上,因为它是装饰函数的装饰器,所以Django框架才提供了method_decorator来解决这个问题

复制代码
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page

@method_decorator(decorator=cache_page(timeout=86400, cache='default'), name='get')
class SubjectView(ListAPIView):
    """获取学科数据的视图类"""
    queryset = Subject.objects.all()
    serializer_class = SubjectSerializer

编程式缓存

编程式缓存是指通过编写的代码来使用缓存服务,虽然代码量会稍微大一些,但是相较于声明式缓存,它对缓存的操作和使用更加灵活,在实际开发中使用得更多

复制代码
# 去掉了之前使用的cache_page装饰器
# 通过django-redis提供的get_redis_connection函数直接获取Redis连接来操作Redis

def show_subjects(request):
    """获取学科数据"""
    redis_cli = get_redis_connection()
    # 先尝试从缓存中获取学科数据
    data = redis_cli.get('vote:polls:subjects')
    if data:
        # 如果获取到学科数据就进行反序列化操作
        data = json.loads(data)
    else:
        # 如果缓存中没有获取到学科数据就查询数据库
        queryset = Subject.objects.all()
        data = SubjectSerializer(queryset, many=True).data
        # 将查到的学科数据序列化后放到缓存中
        redis_cli.set('vote:polls:subjects', json.dumps(data), ex=86400)
    return Response({'code': 20000, 'subjects': data})

需要说明的是,Django框架提供了cache和caches两个现成的变量来支持缓存操作,前者访问的是默认的缓存(名为default的缓存),后者可以通过索引运算获取指定的缓存服务(例如:caches'default')。向cache对象发送get和set消息就可以实现对缓存的读和写操作,但是这种方式能做的操作有限,不如上面中使用的方式灵活。其次,由于可以通过get_redis_connection函数获得的Redis连接对象向Redis发起各种操作,包括FLUSHDB、SHUTDOWN等危险的操作,所以在实际商业项目开发中,一般都会对django-redis再做一次封装,例如封装一个工具类,其中只提供了项目需要用到的缓存操作的方法,从而避免了直接使用get_redis_connection的潜在风险。当然,自己封装对缓存的操作还可以使用"Read Through"和"Write Through"的方式实现对缓存的更新

缓存相关问题

缓存数据的更新

在使用缓存时,当数据改变时,如何更新缓存中的数据?通常更新缓存有如下几种方法,分别是:

  • Cache Aside Pattern
  • Read/Write Through Pattern
  • Write Behind Caching Pattern

第1种方式的具体做法是,当数据更新时,先更新数据库,再删除缓存。当然,先更新数据库再删除缓存的做法在理论上也存在风险,但是发生问题的概率是极低的。不过这种方式相当于编写业务代码的开发者要自己负责对两套存储系统(缓存和关系型数据库)的操作,代码写起来非常的繁琐

注意,不能够使用先更新数据库再更新缓存的方式,也不能够使用先删除缓存再更新数据库的方式(考虑一下有并发的读操作和写操作的场景)

第2种方式的主旨是将后端的存储系统变成一套代码,对缓存的维护封装在这套代码中。其中,Read Through指在查询操作中更新缓存,也就是说,当缓存失效的时候,由缓存服务自己负责对数据的加载,从而对应用方是透明的;而Write Through是指在更新数据时,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由缓存服务自己更新数据库(同步更新)。如果对项目中的Redis操作再做一次封装,就可以实现"Read Through"和"Write Through"模式,虽然会增加工作量,但无疑是一件"一劳永逸"且"功在千秋"的事情

第3种方式是在更新数据的时候,只更新缓存,不更新数据库,而缓存服务这边会异步的批量更新数据库。虽然会大幅度提升性能,但会牺牲数据的强一致性,而且实现逻辑比较复杂,需要追踪有哪些数据是被更新了的,然后再批量的刷新到持久层上

缓存穿透

缓存是为了缓解数据库压力而添加的一个中间层,如果恶意的访问者频繁的访问缓存中没有的数据,那么缓存就失去了存在的意义瞬间所有请求的压力都落在了数据库上,这样会导致数据库承载着巨大的压力甚至连接异常,类似于分布式拒绝服务攻击(DDoS)的做法。解决缓存穿透的一个办法是约定如果查询返回为空值,把这个空值也缓存起来,但是需要为这个空值的缓存设置一个较短的超时时间,毕竟缓存这样的值就是对缓存空间的浪费。另一个解决缓存穿透的办法是使用布隆过滤器

缓存击穿

在实际的项目中,可能存在某个缓存的key某个时间点过期,但恰好在这个时间点对有对该key的大量的并发请求过来,这些请求没有从缓存中找到key对应的数据,就会直接从数据库中获取数据并写回到缓存,这个时候大并发的请求可能会瞬间把数据库压垮,这种现象称为缓存击穿。比较常见的解决缓存击穿的办法是使用互斥锁,简单的说就是在缓存失效的时候,不是立即去数据库加载数据,而是先设置互斥锁(例如:Redis中的setnx),只有设置互斥锁的操作成功的请求,才能执行查询从数据库中加载数据并写入缓存,其他设置互斥锁失败的请求,可以先执行一个短暂的休眠,然后尝试重新从缓存中获取数据,如果缓存还没有数据,则重复刚才的设置互斥锁的操作

复制代码
data = redis_cli.get(key)
while not data:
    if redis_cli.setnx('mutex', 'x'):
        redis.expire('mutex', timeout)
        data = db.query(...)
        redis.set(key, data)
        redis.delete('mutex')
    else:
        time.sleep(0.1)
        data = redis_cli.get(key)

缓存雪崩

缓存雪崩是指在将数据放入缓存时采用了相同的过期时间,这样就导致缓存在某一时刻同时失效,请求全部转发到数据库,导致数据库瞬时压力过大而崩溃。解决缓存雪崩问题的方法也比较简单,可以在既定的缓存过期时间上加一个随机时间,这样可以从一定程度上避免不同的key在同一时间集体失效。还有一种办法就是使用多级缓存,每一级缓存的过期时间都不一样,这样的话即便某个级别的缓存集体失效,但是其他级别的缓存还能够提供数据,避免所有的请求都落到数据库上

43、第四十三天

接入三方平台

在Web应用的开发过程中,有一些任务并不是个人能够完成的。例如,Web项目中需要做个人或企业的实名认证,很显然个人没有能力判断用户提供的认证信息的真实性,因此要借助三方平台提供的服务来完成该项操作。再比如说,项目中需要提供在线支付功能,这类业务通常也是借助支付网关来完成,只需要接入像微信、支付宝、银联这样的三方平台即可

在项目中接入三方平台基本上就两种方式:API接入和SDK接入

1、API接入指的是通过访问三方提供的URL来完成操作或获取数据。国内有很多这样的平台提供了大量常用的服务,例如聚合数据上提供了生活服务类、金融科技类、交通地理类、充值缴费类等各种类型的API。可以通过Python程序发起网络请求,通过访问URL获取数据,这些API接口跟项目中提供的数据接口是一样的,只不过项目中的API是供自己使用的,而这类三方平台提供的API是开放的

2、SDK接入指的是通过安装三方库并使用三方库封装的类、函数来使用三方平台提供的服务的方式。例如接入支付宝,就需要先安装支付宝的SDK,然后通过支付宝封装的类和方法完成对支付服务的调用

异步任务和定时任务

在Web应用中,如果一个请求执行了耗时间的操作或者该请求的执行时间无法确定,而且对于用户来说只需要知道服务器接收了他的请求,并不需要马上得到请求的执行结果,这样的操作就应该对其进行异步化处理。如果说使用缓存是优化网站性能的第一要义,那么将耗时间或执行时间不确定的任务异步化则是网站性能优化的第二要义,简单的说就是能推迟做的事情都不要马上做

以发短信和上传文件到云存储为例,这两个操作前者属于时间不确定的操作(因为作为调用者不能确定三方平台响应的时间),后者属于耗时间的操作(如果文件较大或者三方平台不稳定,都可能导致上传的时间较长),很显然,这两个操作都可以做异步化处理

44、第四十四天

爬虫(crawler)也经常被称为网络蜘蛛(spider),是按照一定的规则自动浏览网站并获取所需信息的机器人程序(自动化脚本代码),被广泛的应用于互联网搜索引擎和数据采集。网页中除了供用户阅读的文字信息之外,还包含一些超链接,网络爬虫正是通过网页中的超链接信息,不断获得网络上其它页面的地址,然后持续的进行数据采集。正因如此,网络数据采集的过程就像一个爬虫或者蜘蛛在网络上漫游,所以才被形象的称为爬虫或者网络蜘蛛

爬虫的应用领域

在理想的状态下,所有 ICP都应该为自己的网站提供 API 接口来共享它们允许其他程序获取的数据,在这种情况下就根本不需要爬虫程序。国内比较有名的电商平台(如淘宝、京东等)、社交平台(如微博、微信等)等都提供了自己的 API 接口,但是这类 API 接口通常会对可以抓取的数据以及抓取数据的频率进行限制。对于大多数的公司而言,及时的获取行业数据和竞对数据是企业生存的重要环节之一,然而对大部分企业来说,数据都是其与生俱来的短板。在这种情况下,合理的利用爬虫来获取数据并从中提取出有商业价值的信息对这些企业来说就显得至关重要的

爬虫的应用领域其实非常广泛

  • 搜索引擎
  • 新闻聚合
  • 社交应用
  • 舆情监控
  • 行业数据

爬虫合法性探讨

网络爬虫这个领域目前还属于拓荒阶段,虽然互联网世界已经通过自己的游戏规则建立起了一定的道德规范,即 Robots 协议(全称是"网络爬虫排除标准"),但法律部分还在建立和完善中,也就是说,现在这个领域暂时还是灰色地带。"法不禁止即为许可",如果爬虫就像浏览器一样获取的是前端显示的数据(网页上的公开信息)而不是网站后台的私密敏感信息,就不太担心法律法规的约束,因为目前大数据产业链的发展速度远远超过了法律的完善程度。在爬取网站的时候,需要限制自己的爬虫遵守 Robots 协议,同时控制网络爬虫程序的抓取数据的速度;在使用数据的时候,必须要尊重网站的知识产权(从Web 2.0时代开始,虽然Web上的数据很多都是由用户提供的,但是网站平台是投入了运营成本的,当用户在注册和发布内容时,平台通常就已经获得了对数据的所有权、使用权和分发权)。如果违反了这些规定,在打官司的时候败诉几率相当高。适当的隐匿自己的身份在编写爬虫程序时必要的,而且最好不要被对方举证你的爬虫有破坏别人动产(例如服务器)的行为。不要在公网(如代码托管平台)上去开源或者展示你的爬虫代码,这些行为通常会给自己带来不必要的麻烦

Robots协议

大多数网站都会定义robots.txt文件,这是一个君子协议,并不是所有爬虫都必须遵守的游戏规则

复制代码
# 淘宝的robots.txt文件为例

User-agent: Baiduspider
Disallow: /

User-agent: baiduspider
Disallow: /

可以看出,淘宝禁止百度爬虫爬取它任何资源,因此当在百度搜索"淘宝"的时候,搜索结果下方会出现:"由于该网站的robots.txt文件存在限制指令(限制搜索引擎抓取),系统无法提供该页面的内容描述"。百度作为一个搜索引擎,至少在表面上遵守了淘宝网的robots.txt协议,所以用户不能从百度上搜索到淘宝内部的产品信息

超文本传输协议(HTTP)

在网页上看到的内容通常是浏览器执行 HTML (超文本标记语言)得到的结果,而 HTTP 就是传输 HTML 数据的协议。HTTP是构建在 TCP(传输控制协议)之上的,它利用了 TCP 提供的可靠的传输服务实现了 Web 应用中的数据交换,设计 HTTP 最初的目的是为了提供一种发布和接收 HTML 页面的方法,也就是说,这个协议是浏览器和 Web 服务器之间传输的数据的载体

此图是访问百度首页时的 HTTP 请求和响应的报文(协议数据)可以清晰的看到从物理链路层到应用层的协议数据

HTTP 请求通常是由请求行、请求头、空行、消息体四个部分构成,如果没有数据发给服务器,消息体就不是必须的部分。请求行中包含了请求方法(GET、POST 等)、资源路径和协议版本;请求头由若干键值对构成,包含了浏览器、编码方式、首选语言、缓存策略等信息;请求头的后面是空行和消息体

HTTP的响应


HTTP 响应通常是由响应行、响应头、空行、消息体四个部分构成,其中消息体是服务响应的数据,可能是 HTML 页面,也有可能是JSON或二进制数据等。响应行中包含了协议版本和响应状态码,其中响应状态码有很多种


下面介绍一些开发爬虫程序的辅助工具,会让使用起来事半功倍

1、Chrome Developer Tools:谷歌浏览器内置的开发者工具

  • 元素(ELements):用于查看或修改 HTML 元素的属性、CSS 属性、监听事件等。CSS 可以即时修改,即时显示,大大方便了开发者调试页面
  • 控制台(Console):用于执行一次性代码,查看 JavaScript 对象,查看调试日志信息或异常信息。控制台其实就是一个执行 JavaScript 代码的交互式环境
  • 源代码(Sources):用于查看页面的 HTML 文件源代码、JavaScript 源代码、CSS 源代码,此外最重要的是可以调试 JavaScript 源代码,可以给代码添加断点和单步执行
  • 网络(Network):用于 HTTP 请求、HTTP 响应以及与网络连接相关的信息
  • 应用(Application):用于查看浏览器本地存储、后台任务等内容,本地存储主要包括Cookie、Local Storage、Session Storage等

2、Postman:功能强大的网页调试与 RESTful 请求工具。Postman可以模拟请求,定制请求以及查看服务器的响应

3、HTTPie:命令行HTTP客户端

复制代码
# 安装
pip install httpie

# 使用
http --header http --header https://movie.douban.com/

HTTP/1.1 200 OK
Connection: keep-alive
Content-Encoding: gzip
Content-Type: text/html; charset=utf-8
Date: Tue, 24 Aug 2021 16:48:00 GMT
Keep-Alive: timeout=30
Server: dae
Set-Cookie: bid=58h4BdKC9lM; Expires=Wed, 24-Aug-22 16:48:00 GMT; Domain=.douban.com; Path=/
Strict-Transport-Security: max-age=15552000
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-DOUBAN-NEWBID: 58h4BdKC9lM

爬虫的基本工作流程

一个基本的爬虫通常分为数据采集(网页下载)、数据处理(网页解析)和数据存储(将有用的信息持久化)三个部分的内容,更为高级的爬虫在数据采集和处理时会使用并发编程或分布式技术因此需要有调度器(安排线程或进程执行对应的任务)、后台管理程序(监控爬虫的工作状态以及检查数据抓取的结果)等的参与

当然更为高级的爬虫在数据采集和处理时会使用并发编程或分布式技术,这就需要有调度器(安排线程或进程执行对应的任务)、后台管理程序(监控爬虫的工作状态以及检查数据抓取的结果)等的参与

一般来说,爬虫的工作流程包括以下几个步骤:

  • 设定抓取目标(种子页面/起始页面)并获取网页
  • 当服务器无法访问时,按照指定的重试次数尝试重新下载页面
  • 在需要的时候设置用户代理或隐藏真实IP,否则可能无法访问页面
  • 对获取的页面进行必要的解码操作然后抓取出需要的信息
  • 在获取的页面中通过某种方式(如正则表达式)抽取出页面中的链接信息
  • 对链接进行进一步的处理(获取页面并重复上面的动作)
  • 将有用的信息进行持久化以备后续的处理

45、第四十五天

requests库

requests是基于 Python 标准库进行了封装,简化了通过 HTTP 或 HTTPS 访问网络资源的操作。HTTP 是一个请求响应式的协议,当在浏览器中输入正确的 URL并按下 Enter 键时,就向网络上的 Web 服务器发送了一个 HTTP 请求,服务器在收到请求后会返回一个 HTTP 响应。在Chrome 浏览器中打开"开发者工具"切换到"Network"选项卡就可以查看 HTTP 请求和响应

通过requests库可以让 Python 程序向浏览器一样向 Web 服务器发起请求,并接收服务器返回的响应,从响应中提取出想要的数据。浏览器呈现的网页是用 HTML 编写的,浏览器相当于是 HTML 的解释器环境,看到的网页中的内容都包含在 HTML 的标签中。在获取到 HTML 代码后,就可以从标签的属性或标签体中提取内容

复制代码
# 如何获取网页 HTML 代码,通过requests库的get函数获取搜狐首页的代码

import requests

resp = requests.get('https://www.sohu.com/')
if resp.status_code == 200:
    print(resp.text)

# 代码中的变量resp是一个Response对象(requests库封装的类型) 
# 通过该对象的status_code属性可以获取响应状态码
# 而该对象的text属性可以帮我们获取到页面的 HTML 代码

因为Response对象的text是一个字符串,所以可以利用正则表达式,从页面的 HTML 代码中提取新闻的标题和链接

复制代码
import re

import requests

pattern = re.compile(r'<a.*?href="(.*?)".*?title="(.*?)".*?>')
resp = requests.get('https://www.sohu.com/')
if resp.status_code == 200:
    all_matches = pattern.findall(resp.text)
    for href, title in all_matches:
        print(href)
        print(title)

除了文本内容,还可以使用requests库通过 URL 获取二进制资源

复制代码
# 如何获取百度 Logo 并保存到名为baidu.png的本地文件中
# 可以在百度的首页上右键点击百度Logo,并通过"复制图片地址"菜单项获取图片的 URL

import requests

resp = requests.get('https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png')
with open('baidu.png', 'wb') as file:
    file.write(resp.content)

# Response对象的content属性可以获得服务器响应的二进制数据

编写爬虫代码

按照上面提供的方法,先使用requests获取到网页的HTML代码,然后将整个代码看成一个长字符串,这样就可以使用正则表达式的捕获组从字符串提取需要的内容

复制代码
# 演示了如何从豆瓣电影获取排前250名的电影的名称,可以看出,每页共展示了25部电影,如果要获取到 Top250 数据,共需要访问10个页面,应的地址是https://movie.douban.com/top250?start=xxx
# 这里的xxx如果为0就是第一页,如果xxx的值是100,那么可以访问到第五页

import random
import re
import time

import requests

for page in range(1, 11):
    resp = requests.get(
        url=f'https://movie.douban.com/top250?start={(page - 1) * 25}',
        # 如果不设置HTTP请求头中的User-Agent,豆瓣会检测出不是浏览器而阻止我们的请求
        # 通过get函数的headers参数设置User-Agent的值,具体的值可以在浏览器的开发者工具查看到
        # 用爬虫访问大部分网站时,将爬虫伪装成来自浏览器的请求都是非常重要的一步
        headers={'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36'}
    )
    # 通过正则表达式获取class属性为title且标签体不以&开头的span标签并用捕获组提取标签内容
    pattern1 = re.compile(r'<span class="title">([^&]*?)</span>')
    titles = pattern1.findall(resp.text)
    # 通过正则表达式获取class属性为rating_num的span标签并用捕获组提取标签内容
    pattern2 = re.compile(r'<span class="rating_num".*?>(.*?)</span>')
    ranks = pattern2.findall(resp.text)
    # 使用zip压缩两个列表,循环遍历所有的电影标题和评分
    for title, rank in zip(titles, ranks):
        print(title, rank)
    # 随机休眠1-5秒,避免爬取页面过于频繁
    time.sleep(random.random() * 4 + 1)

# 通过分析豆瓣网的robots协议,发现豆瓣网并不拒绝百度爬虫获取它的数据,因此也可以将爬虫伪装成百度的爬虫,将get函数的headers参数修改为:
# headers={'User-Agent': 'BaiduSpider'}

使用 IP 代理

让爬虫程序隐匿自己的身份是比较重要的,很多网站对爬虫都比较反感的,因为爬虫会耗费掉它们很多的网络带宽并制造很多无效的流量。要隐匿身份通常需要使用商业 IP 代理,让被爬取的网站无法获取爬虫程序来源的真实 IP 地址,也就无法简单的通过 IP 地址对爬虫程序进行封禁

首先需要在该网站注册一个账号,注册账号后就可以购买相应的套餐来获得商业 IP 代理,蘑菇代理提供了两种接入代理的方式,分别是 API 私密代理和 HTTP 隧道代理,前者是通过请求蘑菇代理的 API 接口获取代理服务器地址,后者是直接使用统一的入口(代理提供的域名)进行接入

复制代码
# HTTP隧道代理:讲解接入 IP 代理的方式
import requests

APP_KEY = 'Wnp******************************XFx'
PROXY_HOST = 'secondtransfer.moguproxy.com:9001'

for page in range(1, 11):
    resp = requests.get(
        url=f'https://movie.douban.com/top250?start={(page - 1) * 25}',
        # 需要在HTTP请求头设置代理的身份认证方式
        headers={
            'Proxy-Authorization': f'Basic {APP_KEY}',
            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36',
            'Accept-Language': 'zh-CN,zh;q=0.8,en-US;q=0.6,en;q=0.4'
        },
        # 设置代理服务器
        proxies={
            'http': f'http://{PROXY_HOST}',
            'https': f'https://{PROXY_HOST}'
        },
        verify=False
    )
    pattern1 = re.compile(r'<span class="title">([^&]*?)</span>')
    titles = pattern1.findall(resp.text)
    pattern2 = re.compile(r'<span class="rating_num".*?>(.*?)</span>')
    ranks = pattern2.findall(resp.text)
    for title, rank in zip(titles, ranks):
        print(title, rank)

# 上面的代码需要修改APP_KEY为自己创建的订单对应的Appkey值

HTML 页面的结构

在浏览器中打开任意一个网站,然后通过鼠标右键菜单,选择"显示网页源代码"菜单项,就可以看到网页对应的 HTML 代码

代码的第1行是文档类型声明,第2行的<html>标签是整个页面根标签的开始标签,最后一行是根标签的结束标签</html>。<html>标签下面有两个子标签<head>和<body>,放在<body>标签下的内容会显示在浏览器窗口中,这部分内容是网页的主体;放在<head>标签下的内容不会显示在浏览器窗口中,但是却包含了页面重要的元信息,通常称之为网页的头部

复制代码
<!doctype html>
<html>
    <head>
        <!-- 页面的元信息,如字符编码、标题、关键字、媒体查询等 -->
    </head>
    <body>
        <!-- 页面的主体,显示在浏览器窗口中的内容 -->
    </body>
</html>

标签、层叠样式表(CSS)、JavaScript 是构成 HTML 页面的三要素,其中标签用来承载页面要显示的内容,CSS 负责对页面的渲染,而 JavaScript 用来控制页面的交互式行为。要实现 HTML 页面的解析,可以使用 XPath 的语法,它原本是 XML 的一种查询语法,可以根据 HTML 标签的层次结构提取标签中的内容或标签属性;此外,也可以使用 CSS 选择器来定位页面元素,就跟用 CSS 渲染页面元素是同样的道理

XPath 解析

XPath 是在 XML(eXtensible Markup Language)文档中查找信息的一种语法,XML 跟 HTML 类似也是一种用标签承载数据的标签语言,不同之处在于 XML 的标签是可扩展的,可以自定义的,而且 XML 对语法有更严格的要求。XPath 使用路径表达式来选取 XML 文档中的节点或者节点集,所说的节点包括元素、属性、文本、命名空间、处理指令、注释、根节点等

复制代码
<?xml version="1.0" encoding="UTF-8"?>
<bookstore>
    <book>
      <title lang="eng">Harry Potter</title>
      <price>29.99</price>
    </book>
    <book>
      <title lang="zh">Learning XML</title>
      <price>39.95</price>
    </book>
</bookstore>
路径表达式 结果
/bookstore 选取根元素 bookstore。注意:假如路径起始于正斜杠( / ),则此路径始终代表到某元素的绝对路径!
//book 选取所有 book 子元素,而不管它们在文档中的位置
//@lang 选取名为 lang 的所有属性
/bookstore/book[1] 选取属于 bookstore 子元素的第一个 book 元素
/bookstore/book[last()] 选取属于 bookstore 子元素的最后一个 book 元素
/bookstore/book[last()-1] 选取属于 bookstore 子元素的倒数第二个 book 元素
/bookstore/book[position()<3] 选取最前面的两个属于 bookstore 元素的子元素的 book 元素
//title[@lang] 选取所有拥有名为 lang 的属性的 title 元素
//title[@lang='eng'] 选取所有 title 元素,且这些元素拥有值为 eng 的 lang 属性
/bookstore/book[price>35.00] 选取 bookstore 元素的所有 book 元素,且其中的 price 元素的值须大于 35.00
/bookstore/book[price>35.00]/title 选取 bookstore 元素中的 book 元素的所有 title 元素,且其中的 price 元素的值须大于 35.00

XPath还支持通配符用法

路径表达式 结果
/bookstore/* 选取 bookstore 元素的所有子元素
//* 选取文档中的所有元素
//title[@*] 选取所有带有属性的 title 元素

实现 XPath 解析需要三方库lxml 的支持,可以使用命令安装lxml

复制代码
pip install lxml

# 下面用 XPath 解析方式改写之前获取豆瓣电影Top250的代码

from lxml import etree
import requests

for page in range(1, 11):
    resp = requests.get(
        url=f'https://movie.douban.com/top250?start={(page - 1) * 25}',
        headers={'User-Agent': 'BaiduSpider'}
    )
    tree = etree.HTML(resp.text)
    # 通过XPath语法从页面中提取电影标题
    title_spans = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[1]/a/span[1]')
    # 通过XPath语法从页面中提取电影评分
    rank_spans = tree.xpath('//*[@id="content"]/div/div[1]/ol/li[1]/div/div[2]/div[2]/div/span[2]')
    for title_span, rank_span in zip(title_spans, rank_spans):
        print(title_span.text, rank_span.text)

CSS 选择器解析

通过 CSS 选择器获取页面元素是更为简单的选择,因为浏览器中运行的 JavaScript 本身就可以document 对象的 querySelector()和querySelectorAll()方法基于 CSS 选择器获取页面元素。。Beautiful Soup 可以用来解析 HTML 和 XML 文档,修复含有未闭合标签等错误的文档,通过为待解析的页面在内存中创建一棵树结构,实现对从页面中提取数据操作的封装

复制代码
pip install beautifulsoup4

# 使用bs4改写的获取豆瓣电影Top250电影名称的代码

import bs4
import requests

for page in range(1, 11):
    resp = requests.get(
        url=f'https://movie.douban.com/top250?start={(page - 1) * 25}',
        headers={'User-Agent': 'BaiduSpider'}
    )
    # 创建BeautifulSoup对象
    soup = bs4.BeautifulSoup(resp.text, 'lxml')
    # 通过CSS选择器从页面中提取包含电影标题的span标签
    title_spans = soup.select('div.info > div.hd > a > span:nth-child(1)')
    # 通过CSS选择器从页面中提取包含电影评分的span标签
    rank_spans = soup.select('div.info > div.bd > div > span.rating_num')
    for title_span, rank_span in zip(title_spans, rank_spans):
        print(title_span.text, rank_span.text)
解析方式 对应的模块 速度 使用难度
正则表达式解析 re 困难
XPath 解析 lxml 一般
CSS 选择器解析 bs4pyquery 不确定 简单

46、第四十六天

线程和进程

通过操作系统运行一个程序会创建出一个或多个进程,进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动。简单的说,进程是操作系统分配存储空间的基本单位,每个进程都有自己的地址空间、数据栈以及其他用于跟踪进程执行的辅助数据;操作系统管理所有进程的执行,为它们合理的分配资源。一个进程可以通过 fork 或 spawn 的方式创建新的进程来执行其他的任务,不过新的进程也有自己独立的内存空间,因此两个进程如果要共享数据,必须通过进程间通信机制来实现,具体的方式包括管道、信号、套接字等

一个进程还可以拥有多个执行线索,简单的说就是拥有多个可以获得 CPU 调度的执行单元,这就是所谓的线程。由于线程在同一个进程下,它们可以共享相同的上下文,因此相对于进程而言,线程间的信息共享和通信更加容易。当然在单核 CPU 系统中,多个线程不可能同时执行,因为在某个时刻只有一个线程能够获得 CPU,多个线程通过共享 CPU 执行时间的方式来达到并发的效果。在程序中使用多线程技术最主要的体现在提升程序的性能和改善用户体验,目前几乎所有使用的软件都用到了多线程技术(可以利用系统自带的进程监控工具证实)

需要再次强调两个概念:并发(concurrency)和并行(parallel)。并发通常是指同一时刻只能有一条指令执行,但是多个线程对应的指令被快速轮换地执行。比如一个处理器,它先执行线程 A 的指令一段时间,再执行线程 B 的指令一段时间,再切回到线程 A 执行一段时间。由于处理器执行指令的速度和切换的速度极快,人们完全感知不到计算机在这个过程中有多个线程切换上下文执行的操作,这就使得宏观上看起来多个线程在同时运行,但微观上其实只有一个线程在执行。并行是指同一时刻,有多条指令在多个处理器上同时执行,并行必须要依赖于多个处理器,不论是从宏观上还是微观上,多个线程可以在同一时刻一起执行的。很多情况下,我们并不严格区分并发和并行,所以也把 Python 中的多线程、多进程以及异步 I/O 都视为实现并发编程的手段,但实际上前面两者也可以实现并行编程

多线程编程

Python 标准库中threading模块的Thread类可以非常轻松的实现多线程编程

复制代码
# 用一个联网下载文件的例子来对比使用多线程和不使用多线程到底有什么区别
# 不使用多线程的下载

import random
import time

def download(*, filename):
    start = time.time()
    print(f'开始下载 {filename}.')
    time.sleep(random.randint(3, 6))
    print(f'{filename} 下载完成.')
    end = time.time()
    print(f'下载耗时: {end - start:.3f}秒.')

def main():
    start = time.time()
    download(filename='Python从入门到住院.pdf')
    download(filename='MySQL从删库到跑路.avi')
    download(filename='Linux从精通到放弃.mp4')
    end = time.time()
    print(f'总耗时: {end - start:.3f}秒.')

if __name__ == '__main__':
    main()

# 上面的代码并没有真正实现联网下载的功能
# 而是通过time.sleep()休眠一段时间来模拟下载文件需要一些时间上的开销

当程序只有一个工作线程时,每个下载任务都需要等待上一个下载任务执行结束才能开始,所以程序执行的总耗时是三个下载任务各自执行时间的总和

复制代码
开始下载Python从入门到住院.pdf.
Python从入门到住院.pdf下载完成.
下载耗时: 3.005秒.
开始下载MySQL从删库到跑路.avi.
MySQL从删库到跑路.avi下载完成.
下载耗时: 5.006秒.
开始下载Linux从精通到放弃.mp4.
Linux从精通到放弃.mp3下载完成.
下载耗时: 6.007秒.
总耗时: 14.018秒.

事实上,三个下载任务之间并没有逻辑上的因果关系,三者是可以"并发"的,下一个下载任务没有必要等待上一个下载任务结束,为此,可以使用多线程编程来改写代码

复制代码
import random
import time
from threading import Thread

def download(*, filename):
    start = time.time()
    print(f'开始下载 {filename}.')
    time.sleep(random.randint(3, 6))
    print(f'{filename} 下载完成.')
    end = time.time()
    print(f'下载耗时: {end - start:.3f}秒.')

def main():
    threads = [
        Thread(target=download, kwargs={'filename': 'Python从入门到住院.pdf'}),
        Thread(target=download, kwargs={'filename': 'MySQL从删库到跑路.avi'}),
        Thread(target=download, kwargs={'filename': 'Linux从精通到放弃.mp4'})
    ]
    start = time.time()
    # 启动三个线程
    for thread in threads:
        thread.start()
    # 等待线程结束
    for thread in threads:
        thread.join()
    end = time.time()
    print(f'总耗时: {end - start:.3f}秒.')

if __name__ == '__main__':
    main()

开始下载 Python从入门到住院.pdf.
开始下载 MySQL从删库到跑路.avi.
开始下载 Linux从精通到放弃.mp4.
MySQL从删库到跑路.avi 下载完成.
下载耗时: 3.005秒.
Python从入门到住院.pdf 下载完成.
下载耗时: 5.006秒.
Linux从精通到放弃.mp4 下载完成.
下载耗时: 6.003秒.
总耗时: 6.004秒.

通过上面的运行结果可以发现,整个程序的执行时间几乎等于耗时最长的一个下载任务的执行时间,这也就意味着,三个下载任务是并发执行的,不存在一个等待另一个的情况,这样做很显然提高了程序的执行效率。简单的说,如果程序中有非常耗时的执行单元,而这些耗时的执行单元之间又没有逻辑上的因果关系,即 B 单元的执行不依赖于 A 单元的执行结果,那么 A 和 B 两个单元就可以放到两个不同的线程中,让他们并发的执行。这样做的好处除了减少程序执行的等待时间,还可以带来更好的用户体验,因为一个单元的阻塞不会造成程序的"假死",因为程序中还有其他的单元是可以运转的

使用 Thread 类创建线程对象

使用Thread类的构造器就可以创建线程对象,而线程对象的start()方法可以启动一个线程。线程启动后会执行target参数指定的函数(前提是获得 CPU 的调度),如果target指定的线程要执行的目标函数有参数,需要通过args参数为其进行指定,对于关键字参数,可以通过kwargs参数进行传入。Thread类的构造器还有很多其他的参数,目前需要掌握的,就是target、args和kwargs

继承 Thread 类自定义线程

除了以上创建线程的方式外,还可以通过继承Thread类并重写run()方法的方式来自定义线程

复制代码
import random
import time
from threading import Thread

class DownloadThread(Thread):

    def __init__(self, filename):
        self.filename = filename
        super().__init__()

    def run(self):
        start = time.time()
        print(f'开始下载 {self.filename}.')
        time.sleep(random.randint(3, 6))
        print(f'{self.filename} 下载完成.')
        end = time.time()
        print(f'下载耗时: {end - start:.3f}秒.')

def main():
    threads = [
        DownloadThread('Python从入门到住院.pdf'),
        DownloadThread('MySQL从删库到跑路.avi'),
        DownloadThread('Linux从精通到放弃.mp4')
    ]
    start = time.time()
    # 启动三个线程
    for thread in threads:
        thread.start()
    # 等待线程结束
    for thread in threads:
        thread.join()
    end = time.time()
    print(f'总耗时: {end - start:.3f}秒.')

if __name__ == '__main__':
    main()

使用线程池

通过线程池的方式将任务放到多个线程中去执行,应该是多线程编程最理想的选择。事实上,线程的创建和释放都会带来较大的开销,频繁的创建和释放线程通常都不是很好的选择。利用线程池,可以提前准备好若干个线程,在使用的过程中不需要再通过自定义的代码创建和释放线程,而是直接复用线程池中的线程,Python 内置的concurrent.futures模块提供了对线程池的支持

复制代码
import random
import time
from concurrent.futures import ThreadPoolExecutor
from threading import Thread

def download(*, filename):
    start = time.time()
    print(f'开始下载 {filename}.')
    time.sleep(random.randint(3, 6))
    print(f'{filename} 下载完成.')
    end = time.time()
    print(f'下载耗时: {end - start:.3f}秒.')

def main():
    with ThreadPoolExecutor(max_workers=4) as pool:
        filenames = ['Python从入门到住院.pdf', 'MySQL从删库到跑路.avi', 'Linux从精通到放弃.mp4']
        start = time.time()
        for filename in filenames:
            pool.submit(download, filename=filename)
    end = time.time()
    print(f'总耗时: {end - start:.3f}秒.')

if __name__ == '__main__':
    main()

守护线程

守护线程就是在主线程结束的时候,不值得再保留的执行线程。不值得保留指的是守护线程会在其他非守护线程全部运行结束之后被销毁,它守护的是当前进程内所有的非守护线程。简单的说,守护线程会跟随主线程一起挂掉,而主线程的生命周期就是一个进程的生命周期

复制代码
import time
from threading import Thread

def display(content):
    while True:
        print(content, end='', flush=True)
        time.sleep(0.1)

def main():
    Thread(target=display, args=('Ping', )).start()
    Thread(target=display, args=('Pong', )).start()

if __name__ == '__main__':
    main()

上面的代码中,将print函数的参数flush设置为True,因为flush参数的值如果为False,而print又没有做换行处理,就会导致每次print输出的内容被放到操作系统的输出缓冲区,直到缓冲区被输出的内容塞满,才会清空缓冲区产生一次输出。上述现象是操作系统为了减少 I/O 中断,提升 CPU 利用率做出的设定,为了让代码产生直观交互,才将flush参数设置为True,强制每次输出都清空输出缓冲区

上面的代码运行起来之后是不会停止的,因为两个子线程中都有死循环,除非手动中断代码的执行。但是,如果在创建线程对象时,将名为daemon的参数设置为True,这两个线程就会变成守护线程,那么在其他线程结束时,即便有死循环,两个守护线程也会挂掉,不会再继续执行下去

复制代码
import time
from threading import Thread

def display(content):
    while True:
        print(content, end='', flush=True)
        time.sleep(0.1)

def main():
    Thread(target=display, args=('Ping', ), daemon=True).start()
    Thread(target=display, args=('Pong', ), daemon=True).start()
    time.sleep(5)

if __name__ == '__main__':
    main()

# 在主线程中添加了一行time.sleep(5)让主线程休眠5秒
# 在这个过程中,输出Ping和Pong的守护线程会持续运转
# 直到主线程在5秒后结束,这两个守护线程也被销毁,不再继续运行

资源竞争

在编写多线程代码时,不可避免的会遇到多个线程竞争同一个资源(对象)的情况。在这种情况下,如果没有合理的机制来保护被竞争的资源,那么就有可能出现非预期的状况

复制代码
# 创建了100个线程向同一个银行账户(初始余额为0元)转账,每个线程转账金额为1元
# 在正常的情况下,银行账户最终的余额应该是100元,但是下面的结果并不能得到100元这个结果

import time
from concurrent.futures import ThreadPoolExecutor

class Account(object):
    """银行账户"""

    def __init__(self):
        self.balance = 0.0

    def deposit(self, money):
        """存钱"""
        new_balance = self.balance + money # 会有更新丢失的问题
        time.sleep(0.01)
        self.balance = new_balance

def main():
    """主函数"""
    account = Account()
    with ThreadPoolExecutor(max_workers=16) as pool:
        for _ in range(100):
            pool.submit(account.deposit, 1)
    print(account.balance)

if __name__ == '__main__':
    main()

Account类代表了银行账户,它的deposit方法代表存款行为,参数money代表存入的金额,该方法通过time.sleep函数模拟受理存款需要一段时间。通过线程池的方式启动了100个线程向一个账户转账,但是上面的代码并不能运行出100这个期望的结果,是因为在多个线程竞争一个资源的时候,可能会遇到的数据不一致的问题。当多个线程都在相同的余额上执行加上存入金额的操作时,这就会造成"丢失更新"现象,即之前修改数据的成果被后续的修改给覆盖掉了

解决上面的问题的话,可以使用锁机制,通过锁对操作数据的关键代码加以保护。Python 标准库的threading模块提供了Lock和RLock类来支持锁机制,建议直接使用RLock

复制代码
# 给银行账户添加一个锁对象,通过锁对象来解决刚才存款时发生"丢失更新"的问题

import time

from concurrent.futures import ThreadPoolExecutor
from threading import RLock

class Account(object):
    """银行账户"""

    def __init__(self):
        self.balance = 0.0
        self.lock = RLock()

    def deposit(self, money):
        # 获得锁
        self.lock.acquire()
        try:
            new_balance = self.balance + money
            time.sleep(0.01)
            self.balance = new_balance
        finally:
            # 释放锁
            self.lock.release()

def main():
    """主函数"""
    account = Account()
    with ThreadPoolExecutor(max_workers=16) as pool:
        for _ in range(100):
            pool.submit(account.deposit, 1)
    print(account.balance)

if __name__ == '__main__':
    main()

上面代码中,获得锁和释放锁的操作也可以通过上下文语法来实现,使用上下文语法会让代码更加简单优雅,是更推荐使用的方式

复制代码
import time

from concurrent.futures import ThreadPoolExecutor
from threading import RLock

class Account(object):
    """银行账户"""

    def __init__(self):
        self.balance = 0.0
        self.lock = RLock()

    def deposit(self, money):
        # 通过上下文语法获得锁和释放锁
        with self.lock:
            new_balance = self.balance + money
            time.sleep(0.01)
            self.balance = new_balance

def main():
    """主函数"""
    account = Account()
    with ThreadPoolExecutor(max_workers=16) as pool:
        for _ in range(100):
            pool.submit(account.deposit, 1)
    print(account.balance)

if __name__ == '__main__':
    main()

GIL问题

官方的 Python 解释器(通常称之为 CPython)运行 Python 程序,其实不能通过使用多线程的方式将 CPU 的利用率提升到逼近400%(对于4核 CPU)或逼近800%(对于8核 CPU)这样的水平,因为 CPython 在执行代码时,会受到 GIL(全局解释器锁

的限制。具体的说,CPython 在执行任何代码时,都需要对应的线程先获得 GIL,然后每执行100条指令,CPython 就会让获得 GIL 的线程主动释放 GIL,这样别的线程才有机会执行。因为 GIL 的存在,无论CPU 有多少个核,编写的 Python 代码也没有机会真正并行的执行。当下,对于 CPython 而言,如果希望充分发挥 CPU 的多核优势,可以考虑使用多进程,因为每个进程都对应一个 Python 解释器,因此每个进程都有自己独立的 GIL,这样就可以突破 GIL 的限制

创建进程

在 Python 中可以基于Process类来创建进程,虽然进程和线程有着本质的差别,但是Process类和Thread类的用法却非常类似。在使用Process类的构造器创建对象时,也是通过target参数传入一个函数来指定进程要执行的代码,而args和kwargs参数可以指定该函数使用的参数值

复制代码
from multiprocessing import Process, current_process
from time import sleep

def sub_task(content, nums):
    # 通过current_process函数获取当前进程对象
    # 通过进程对象的pid和name属性获取进程的ID号和名字
    print(f'PID: {current_process().pid}')
    print(f'Name: {current_process().name}')
    # 通过下面的输出不难发现,每个进程都有自己的nums列表,进程之间本就不共享内存
    # 在创建子进程时复制了父进程的数据结构,三个进程从列表中pop(0)得到的值都是20
    counter, total = 0, nums.pop(0)
    print(f'Loop count: {total}')
    sleep(0.5)
    while counter < total:
        counter += 1
        print(f'{counter}: {content}')
        sleep(0.01)

def main():
    nums = [20, 30, 40]
    # 创建并启动进程来执行指定的函数
    Process(target=sub_task, args=('Ping', nums)).start()
    Process(target=sub_task, args=('Pong', nums)).start()
    # 在主进程中执行sub_task函数
    sub_task('Good', nums)

if __name__ == '__main__':
    main()

可以使用os模块的fork函数来创建进程,调用该函数时,操作系统自动把当前进程(父进程)复制一份(子进程),父进程的fork函数会返回子进程的ID,而子进程中的fork函数会返回0,也就是说这个函数调用一次会在父进程和子进程中得到两个不同的返回值。需要注意的是,Windows 系统并不支持fork函数

简而言之,推荐直接使用Process类、继承Process类和使用进程池这三种方式来创建和使用多进程,这三种方式不同于上面的fork函数,能够保证代码的兼容性和可移植性

多进程和多线程的比较

对于爬虫这类 I/O 密集型任务来说,使用多进程并没有什么优势;但是对于计算密集型任务来说,多进程相比多线程,在效率上会有显著的提升。由于 GIL 的存在,多线程无法利用 CPU 的多核特性,其 CPU 利用率最多只能达到 100%。而多进程可以绕过 GIL 的限制,让多个核心真正并行工作,从而大幅提升计算效率。因此,对于科学计算、图像处理、音视频编解码等可并行的计算密集型任务,多进程无疑是更好的选择

进程间通信

复制代码
# 任务:启动两个进程,一个输出"Ping",一个输出"Pong",两个进程输出的"Ping"和"Pong"加起来一共有50个时,就结束程序
# 但是在实际编写代码时,由于多个进程之间不能够像多个线程之间直接通过共享内存的方式交换数据
# 所以下面的代码是达不到想要的结果的

from multiprocessing import Process
from time import sleep

counter = 0

def sub_task(string):
    global counter
    while counter < 50:
        print(string, end='', flush=True)
        counter += 1
        sleep(0.01)
       
def main():
    Process(target=sub_task, args=('Ping', )).start()
    Process(target=sub_task, args=('Pong', )).start()

if __name__ == '__main__':
    main()

虽然代码看起来没有问题,但是最后的结果是"Ping"和"Pong"各输出了50个,当在程序中创建进程的时候,子进程会复制父进程及其所有的数据结构,每个子进程有自己独立的内存空间,这也就意味着两个子进程中各有一个counter变量,它们都会从0加到50,所以结果就可想而知了。解决这个问题比较简单的办法是使用multiprocessing模块中的Queue类,它是可以被多个进程共享的队列,底层是通过操作系统底层的管道和信号量(semaphore)机制来实现

复制代码
import time
from multiprocessing import Process, Queue

def sub_task(content, queue):
    counter = queue.get()
    while counter < 50:
        print(content, end='', flush=True)
        counter += 1
        queue.put(counter)
        time.sleep(0.01)
        counter = queue.get()

def main():
    queue = Queue()
    queue.put(0)
    p1 = Process(target=sub_task, args=('Ping', queue))
    p1.start()
    p2 = Process(target=sub_task, args=('Pong', queue))
    p2.start()
    while p1.is_alive() and p2.is_alive():
        pass
    queue.put(50)

if __name__ == '__main__':
    main()

​# multiprocessing.Queue对象的get方法默认在队列为空时是会阻塞的,直到获取到数据才会返回
# 如果不希望该方法阻塞以及需要指定阻塞的超时时间,可以通过指定block和timeout参数进行设定

通过Queue类的get和put方法让三个进程(p1、p2和主进程)实现了数据的共享,这就是所谓的进程间的通信,通过这种方式,当Queue中取出的值已经大于等于50时,p1和p2就会跳出while循环,从而终止进程的执行。main方法中的循环是为了等待p1和p2两个进程中的一个结束,这时候主进程还需要向Queue中放置一个大于等于50的值,这样另一个尚未结束的进程也会因为读到这个大于等于50的值而终止。进程间通信的方式还有很多,使用套接字也可以实现两个进程的通信,甚至于这两个进程并不在同一台主机上

总结

在 Python 中,可以通过subprocess 模块的call 函数执行其他的命令来创建子进程,相当于就是在程序中调用其他程序。对于Python开发者来说,以下情况需要考虑使用多线程:

  • 程序需要维护许多共享的状态(尤其是可变状态),Python 中的列表、字典、集合都是线程安全的(多个线程同时操作同一个列表、字典或集合,不会引发错误和数据问题),所以使用线程而不是进程维护共享状态的代价相对较小
  • 程序会花费大量时间在 I/O 操作上,没有太多并行计算的需求且不需占用太多的内存

那么在遇到下列情况时,应该考虑使用多进程:

  • 程序执行计算密集型任务(如:音视频编解码、数据压缩、科学计算等)
  • 程序的输入可以并行的分成块,并且可以将运算结果合并
  • 程序在内存使用方面没有任何限制且不强依赖于 I/O 操作(如读写文件、套接字等)

爬虫是典型的 I/O 密集型任务,I/O 密集型任务的特点就是程序会经常性的因为 I/O 操作而进入阻塞状态,比如使用requests获取页面代码或二进制内容,发出一个请求之后,程序必须要等待网站返回响应之后才能继续运行,如果目标网络状况不是很理想,那么等待响应的时间可能过长,而在这个过程中整个程序是一直阻塞在那里,没有做任何的事情。可以通过多线程的方式为爬虫提速,本质就是当一个线程阻塞的时候,程序还有其他的线程可以继续运转,因此整个程序就不会在阻塞和等待中浪费了大量的时间

还有一种非常适合 I/O 密集型任务的并发编程方式,称之为异步编程(或者异步 I/O)。这种方式并不需要启动多个线程或多个进程来实现并发,它是通过多个子程序相互协作的方式来提升 CPU 的利用率,解决了 I/O 密集型任务 CPU 利用率很低的问题,一般将这种方式称为"协作式并发"

基础概念

**阻塞:**阻塞状态指程序未得到所需计算资源时被挂起的状态。程序在等待某个操作完成期间,自身无法继续处理其他的事情,则称该程序在该操作上是阻塞的。阻塞随时都可能发生,最典型的就是 I/O 中断(包括网络 I/O 、磁盘 I/O 、用户输入等)、休眠操作、等待某个线程执行结束,甚至包括在 CPU 切换上下文时,程序都无法真正的执行,这就是所谓的阻塞

**非阻塞:**程序在等待某操作过程中,自身不被阻塞,可以继续处理其他的事情,则称该程序在该操作上是非阻塞的。非阻塞并不是在任何程序级别、任何情况下都可以存在的。仅当程序封装的级别可以囊括独立的子程序单元时,它才可能存在非阻塞状态。显然,某个操作的阻塞可能会导程序耗时以及效率低下,所以希望把它变成非阻塞的

**同步:**不同程序单元为了完成某个任务,在执行过程中需靠某种通信方式以协调一致,称这些程序单元是同步执行的。例如给银行账户存钱的操作,使用了"锁"作为通信信号,让多个存钱操作强制排队顺序执行,这就是所谓的同步

**异步:**不同程序单元在执行过程中无需通信协调,也能够完成一个任务,这种方式就称之为异步。例如,使用爬虫下载页面时,调度程序调用下载程序后,即可调度其他任务,而无需与该下载任务保持通信以协调行为。不同网页的下载、保存等操作都是不相关的,也无需相互通知协调。很显然,异步操作的完成时刻和先后顺序并不能确定

总结一下,同步与异步的关注点是消息通信机制,最终表现出来的是"有序"和"无序"的区别;阻塞和非阻塞的关注点是程序在等待消息时状态,最终表现出来的是程序在等待时能不能做点别的

生成器和协程

异步编程是一种"协作式并发",即通过多个子程序相互协作的方式提升 CPU 的利用率,从而减少程序在阻塞和等待中浪费的时间,最终达到并发的效果。可以将多个相互协作的子程序称为"协程",它是实现异步编程的关键

复制代码
def fib(max_count):
    a, b = 0, 1
    for _ in range(max_count):
        a, b = b, a + b
        yield a  # 关键:使用 yield 而不是 return

# 编写了一个生成斐波那契数列的生成器,调用上面的fib函数并不是执行该函数获得返回值
# 因为fib函数中有一个特殊的关键字yield
# 这个关键字使得fib函数跟普通的函数有些区别,调用该函数会得到一个生成器对象

g = fib(20)
print(g)

# 输出:
<generator object fib at 0x106daee40>

可以使用内置函数next从生成器对象中获取斐波那契数列的值,也可以通过for-in循环对生成器能够提供的值进行遍历

复制代码
for value in gen_obj:
    print(value)

生成器经过预激活,就是一个协程,它可以跟其他子程序协作

复制代码
def calc_average():
    total, counter = 0, 0
    avg_value = None
    while True:
        curr_value = yield avg_value
        total += curr_value
        counter += 1
        avg_value = total / counter

def main():
    obj = calc_average()
    # 生成器预激活
    obj.send(None)
    for _ in range(5):
        print(obj.send(float(input())))

if __name__ == '__main__':
    main()

首先通过生成器对象的send方法发送一个None值来将其激活为协程,也可以通过next(obj)达到同样的效果。接下来,协程对象会接收main函数发送的数据并产出(yield)数据的平均值

异步函数

Python 3.5版本中,引入了两个元素,一个叫async,一个叫await,并在Python 3.7版本中成为了正式的关键字。通过这两个关键字,可以简化协程代码的编写,可以用更为简单的方式让多个子程序很好的协作起来

复制代码
import time

def display(num):
    time.sleep(1)
    print(num)

def main():
    start = time.time()
    for i in range(1, 10):
        display(i)
    end = time.time()
    print(f'{end - start:.3f}秒')

if __name__ == '__main__':
    main()

效果为每次执行都会依次输出1到9的数字,每个间隔1秒钟,整个代码需要执行大概需要9秒多的时间。这段代码就是以同步和阻塞的方式执行的,同步可以从代码的输出看出来,而阻塞是指在调用display函数发生休眠时,整个代码的其他部分都不能继续执行,必须等待休眠结束

接下来,尝试用异步的方式改写上面的代码,让display函数以异步的方式运转

复制代码
import asyncio
import time

async def display(num):
    await asyncio.sleep(1)  # 模拟异步IO操作
    print(num)

def main():
    start = time.time()
    # 1. 创建9个协程对象(但还未执行)
    objs = [display(i) for i in range(1, 10)]
    
    # 2. 获取事件循环
    loop = asyncio.get_event_loop()
    
    # 3. 并发执行所有协程
    loop.run_until_complete(asyncio.wait(objs))
    
    loop.close()
    print(f'{time.time() - start:.3f}秒')

# 输出结果(顺序可能不同):
# 1 2 3 4 5 6 7 8 9(乱序)
# 1.012秒

Python 中的asyncio模块提供了对异步 I/O 的支持。首先在display函数前面加上了async关键字使其变成一个异步函数,调用异步函数不会执行函数体而是获得一个协程对象。将display函数中的time.sleep(1)修改为await asyncio.sleep(1),二者的区别在于,后者不会让整个代码陷入阻塞,因为await 操作会让其他协作的子程序有获得 CPU 资源而得以运转的机会。为了让这些子程序可以协作起来,需要将其放到一个事件循环(实现消息分派传递的系统)上,因为当协程遭遇 I/O 操作阻塞时,就会到事件循环中监听 I/O 操作是否完成,并注册自身的上下文以及自身的唤醒函数(以便恢复执行),之后该协程就变为阻塞状态。上面代码创建了9个协程对象并放到一个列表中,再通过asyncio模块的get_event_loop函数获得了系统的事件循环,通过asyncio模块的run_until_complete函数将协程对象挂载到事件循环上。执行上面的代码会发现,9个分别会阻塞1秒钟的协程总共只阻塞了约1秒种的时间,因为阻塞的协程对象会放弃对 CPU 的占有而不是让 CPU 处于闲置状态,这种方式大大的提升了 CPU 的利用率。而且还会注意到,数字并不是按照从1到9的顺序打印输出的,这正是想要的结果,说明它们是异步执行的。对于爬虫这样的 I/O 密集型任务来说,这种协作式并发在很多场景下是比使用多线程更好的选择,因为这种做法减少了管理和维护多个线程以及多个线程切换所带来的开销

aiohttp库

requests三方库并不支持异步 I/O,如果希望使用异步 I/O 的方式来加速爬虫代码的执行,可以安装和使用名为aiohttp的三方库

复制代码
# 安装aiohttp

pip install aiohttp

# 使用aiohttp抓取了10个网站的首页并解析出它们的标题

import asyncio
import re

import aiohttp
from aiohttp import ClientSession

TITLE_PATTERN = re.compile(r'<title.*?>(.*?)</title>', re.DOTALL)

# 1. 定义协程函数:获取单个页面的标题
async def fetch_page_title(url):
    # 创建HTTP会话
    async with aiohttp.ClientSession(headers={...}) as session:
        # 发起GET请求
        async with session.get(url, ssl=False) as resp:
            if resp.status == 200:
                html_code = await resp.text()  # 异步读取响应
                title = TITLE_PATTERN.search(html_code).group(1).strip()
                print(title)

# 2. 并发执行10个任务
def main():
    urls = [
        'https://www.python.org/',
        'https://www.jd.com/',
        'https://www.baidu.com/',
        'https://www.taobao.com/',
        'https://git-scm.com/',
        'https://www.sohu.com/',
        'https://gitee.com/',
        'https://www.amazon.com/',
        'https://www.usa.gov/',
        'https://www.nasa.gov/'
    ]
    objs = [fetch_page_title(url) for url in urls]
    loop = asyncio.get_event_loop()
    loop.run_until_complete(asyncio.wait(objs))
    loop.close()


if __name__ == '__main__':
    main()

# 输出:

京东(JD.COM)-正品低价、品质保障、配送及时、轻松购物!
搜狐
淘宝网 - 淘!我喜欢
百度一下,你就知道
Gitee - 基于 Git 的代码托管和研发协作平台
Git
NASA
Official Guide to Government Information and Services   &#124; USAGov
Amazon.com. Spend less. Smile more.
Welcome to Python.org

从上面的输出可以看出,网站首页标题的输出顺序跟它们的 URL 在列表中的顺序没有关系。代码创建了ClientSession对象,通过它的get方法可以向指定的 URL 发起请求,跟requests中的Session对象并没有本质区别,唯一的区别是这里使用了异步上下文。而await会让因为 I/O 操作阻塞的子程序放弃对 CPU 的占用,这使得其他的子程序可以运转起来去抓取页面。使用正则表达式捕获组操作解析网页标题,fetch_page_title是一个被async关键字修饰的异步函数,调用该函数会获得协程对象

47、第四十七天

使用Selenium抓取网页动态内容

根据权威机构发布的全球互联网可访问性审计报告,全球约有四分之三的网站其内容或部分内容是通过JavaScript动态生成的,这就意味着在浏览器窗口中"查看网页源代码"时无法在HTML代码中找到这些内容,之前用的抓取数据的方式无法正常运转。解决问题的方案基本上有两种,一是获取提供动态内容的数据接口,这种方式也适用于抓取手机 App 的数据;另一种是通过自动化测试工具 Selenium 运行浏览器获取渲染后的动态内容。对于第一种方案,可以使用浏览器的"开发者工具"或者更为专业的抓包工具(如:Charles、Fiddler、Wireshark等)来获取到数据接口

Selenium 介绍

Selenium 是一个自动化测试工具,因其可以驱动浏览器执行特定的行为,最终帮助爬虫开发者获取到网页的动态内容。简单来说,对于在浏览器窗口中能够看到的内容,都可以使用 Selenium 获取到,针对于那些使用了 JavaScript 动态渲染技术的网站,Selenium 会是一个重要的选择

复制代码
# 通过pip来安装 Selenium

pip install selenium

加载页面

复制代码
# 通过下面的代码驱动 Chrome 浏览器打开百度

from selenium import webdriver

# 创建Chrome浏览器对象
browser = webdriver.Chrome()
# 加载指定的页面
browser.get('https://www.baidu.com/')

如果不愿意使用 Chrome 浏览器,也可以修改上面的代码操控其他浏览器,只需创建对应的浏览器对象(如 Firefox、Safari 等)即可

查找元素和模拟用户行为

在完成页面加载后,可以通过Chrome对象的find_element和find_elements方法来获取页面元素,Selenium 支持多种获取元素的方式,包括:CSS 选择器、XPath、元素名字(标签名)、元素 ID、类名等,前者可以获取单个页面元素(WebElement对象),后者可以获取多个页面元素构成的列表。获取到WebElement对象以后,可以通过send_keys来模拟用户输入行为,可以通过click来模拟用户点击操作

复制代码
# 模拟用户在百度首页的文本框输入搜索关键字并点击"百度一下"按钮

from selenium import webdriver
from selenium.webdriver.common.by import By

browser = webdriver.Chrome()
browser.get('https://www.baidu.com/')
# 通过元素ID获取元素
kw_input = browser.find_element(By.ID, 'kw')
# 模拟用户输入行为
kw_input.send_keys('Python')
# 通过CSS选择器获取元素
su_button = browser.find_element(By.CSS_SELECTOR, '#su')
# 模拟用户点击行为
su_button.click()

隐式等待和显式等待

网页上的元素可能是动态生成的,在使用find_element或find_elements方法获取的时候,可能还没有完成渲染,这时会引发NoSuchElementException错误。为了解决这个问题,可以使用隐式等待的方式,通过设置等待时间让浏览器完成对页面元素的渲染。除此之外,还可以使用显示等待,通过创建WebDriverWait对象,并设置等待时间和条件,当条件没有满足时,可以先等待再尝试进行后续的操作

复制代码
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait

browser = webdriver.Chrome()
# 设置浏览器窗口大小
browser.set_window_size(1200, 800)
browser.get('https://www.baidu.com/')
# 设置隐式等待时间为10秒
browser.implicitly_wait(10)
kw_input = browser.find_element(By.ID, 'kw')
kw_input.send_keys('Python')
su_button = browser.find_element(By.CSS_SELECTOR, '#su')
su_button.click()

# 创建显示等待对象
wait_obj = WebDriverWait(browser, 10)
# 设置等待条件(等搜索结果的div出现)
wait_obj.until(
    expected_conditions.presence_of_element_located(
        (By.CSS_SELECTOR, '#content_left')
    )
)
# 截屏
browser.get_screenshot_as_file('python_result.png')

# 设置的等待条件presence_of_element_located表示等待指定元素出现
等待条件 具体含义
title_is / title_contains 标题是指定的内容 / 标题包含指定的内容
visibility_of 元素可见
presence_of_element_located 定位的元素加载完成
visibility_of_element_located 定位的元素变得可见
invisibility_of_element_located 定位的元素变得不可见
text_to_be_present_in_element 元素包含指定的内容
text_to_be_present_in_element_value 元素的value属性包含指定的内容
element_to_be_clickable 元素可点击
element_to_be_selected 元素被选中

执行JavaScript代码

如果希望在浏览器窗口中加载更多的内容,可以通过浏览器对象的execute_scripts方法执行 JavaScript 代码来实现。对于一些高级的爬取操作,也会使用到类似的操作,如果爬虫代码需要 JavaScript 的支持,可以先对JavaScript有一定了解,特别是JavaScript 中的 BOM 和 DOM 操作

复制代码
# 执行JavaScript代码
browser.execute_script('document.documentElement.scrollTop = document.documentElement.scrollHeight')

Selenium反爬的破解

有一些网站专门针对 Selenium 设置了反爬措施,因为使用 Selenium 驱动的浏览器,在控制台中可以看到webdriver属性值为true,如果要绕过这项检查,可以在加载页面之前,先通过执行 JavaScript 代码将其修改为undefined

另一方面,还可以将浏览器窗口上的"Chrome正受到自动测试软件的控制"隐藏掉

复制代码
# 创建Chrome参数对象
options = webdriver.ChromeOptions()
# 添加试验性参数
options.add_experimental_option('excludeSwitches', ['enable-automation'])
options.add_experimental_option('useAutomationExtension', False)
# 创建Chrome浏览器对象并传入参数
browser = webdriver.Chrome(options=options)
# 执行Chrome开发者协议命令(在加载页面时执行指定的JavaScript代码)
browser.execute_cdp_cmd(
    'Page.addScriptToEvaluateOnNewDocument',
    {'source': 'Object.defineProperty(navigator, "webdriver", {get: () => undefined})'}
)
browser.set_window_size(1200, 800)
browser.get('https://www.baidu.com/')

无头浏览器

很多时候,在爬取数据时并不需要看到浏览器窗口,只要有 Chrome 浏览器以及对应的驱动程序,爬虫就能够运转起来。如果不想看到浏览器窗口,可以通过下面的方式设置使用无头浏览器

复制代码
options = webdriver.ChromeOptions()
options.add_argument('--headless')
browser = webdriver.Chrome(options=options)

48、第四十八天

Scrapy 概述

Scrapy 是基于 Python 的一个非常流行的网络爬虫框架,可以用来抓取 Web 站点并从页面中提取结构化的数据

Scrapy的组件

  • Scrapy 引擎(Engine):用来控制整个系统的数据处理流程
  • 调度器(Scheduler):调度器从引擎接受请求并排序列入队列,并在发出请求后返还给它们
  • 下载器(Downloader):下载器的主要职责是抓取网页并将网页内容返还给蜘蛛(Spiders)
  • 蜘蛛程序(Spiders):蜘蛛是用户自定义的用来解析网页并抓取特定URL的类,每个蜘蛛都能处理一个域名或一组域名,简单的说就是用来定义特定网站的抓取和解析规则的模块
  • 数据管道(Item Pipeline):管道的主要责任是负责处理有蜘蛛从网页中抽取的数据条目,它的主要任务是清理、验证和存储数据。当页面被蜘蛛解析后,将被发送到数据管道,并经过几个特定的次序处理数据。每个数据管道组件都是一个 Python 类,它们获取了数据条目并执行对数据条目进行处理的方法,同时还需要确定是否需要在数据管道中继续执行下一步或是直接丢弃掉不处理。数据管道通常执行的任务有:清理 HTML 数据、验证解析到的数据(检查条目是否包含必要的字段)、检查是不是重复数据(如果重复就丢弃)、将解析到的数据存储到数据库(关系型数据库或 NoSQL 数据库)中
  • 中间件(Middlewares):中间件是介于引擎和其他组件之间的一个钩子框架,主要是为了提供自定义的代码来拓展 Scrapy 的功能,包括下载器中间件和蜘蛛中间件

数据处理流程

Scrapy 的整个数据处理流程由引擎进行控制,通常的运转流程包括以下的步骤:

  1. 引擎询问蜘蛛需要处理哪个网站,并让蜘蛛将第一个需要处理的 URL 交给它
  2. 引擎让调度器将需要处理的 URL 放在队列中
  3. 引擎从调度那获取接下来进行爬取的页面
  4. 调度将下一个爬取的 URL 返回给引擎,引擎将它通过下载中间件发送到下载器
  5. 当网页被下载器下载完成以后,响应内容通过下载中间件被发送到引擎;如果下载失败了,引擎会通知调度器记录这个 URL,待会再重新下载
  6. 引擎收到下载器的响应并将它通过蜘蛛中间件发送到蜘蛛进行处理
  7. 蜘蛛处理响应并返回爬取到的数据条目,此外还要将需要跟进的新的 URL 发送给引擎
  8. 引擎将抓取到的数据条目送入数据管道,把新的 URL 发送给调度器放入队列中

会一直重复直到调度器中没有需要请求的 URL,爬虫就停止工作

安装和使用Scrapy

复制代码
# 可以使用 Python 的包管理工具pip来安装 Scrapy
pip install scrapy

# 在命令行中使用scrapy命令创建名为demo的项目
scrapy startproject demo

# 项目的目录结构

demo
|____ demo
|________ spiders
|____________ __init__.py
|________ __init__.py
|________ items.py
|________ middlewares.py
|________ pipelines.py
|________ settings.py
|____ scrapy.cfg

# 切换到demo 目录,创建名为douban的蜘蛛程序
scrapy genspider douban movie.douban.com

一个简单的例子

接下来,实现一个爬取豆瓣电影 Top250 电影标题、评分和金句的爬虫

1、在items.py的Item类中定义字段,这些字段用来保存数据,方便后续的操作

复制代码
import scrapy

class DoubanItem(scrapy.Item):
    title = scrapy.Field()
    score = scrapy.Field()
    motto = scrapy.Field()

2、修改spiders文件夹中名为douban.py 的文件,它是蜘蛛程序的核心,需要添加解析页面的代码,可以通过对Response对象的解析,获取电影的信息

复制代码
import scrapy
from scrapy import Selector, Request
from scrapy.http import HtmlResponse

from demo.items import MovieItem

class DoubanSpider(scrapy.Spider):
    name = 'douban'
    allowed_domains = ['movie.douban.com']
    start_urls = ['https://movie.douban.com/top250?start=0&filter=']

    def parse(self, response: HtmlResponse):
        sel = Selector(response)
        movie_items = sel.css('#content > div > div.article > ol > li')
        for movie_sel in movie_items:
            item = MovieItem()
            item['title'] = movie_sel.css('.title::text').extract_first()
            item['score'] = movie_sel.css('.rating_num::text').extract_first()
            item['motto'] = movie_sel.css('.inq::text').extract_first()
            yield item

如果还要生成后续爬取的请求,可以用yield产出Request对象。Request对象有两个非常重要的属性,一个是url,它代表了要请求的地址;一个是callback,代表了获得响应之后要执行的回调函数

复制代码
import scrapy
from scrapy import Selector, Request
from scrapy.http import HtmlResponse

from demo.items import MovieItem

class DoubanSpider(scrapy.Spider):
    name = 'douban'
    allowed_domains = ['movie.douban.com']
    start_urls = ['https://movie.douban.com/top250?start=0&filter=']

    def parse(self, response: HtmlResponse):
        sel = Selector(response)
        movie_items = sel.css('#content > div > div.article > ol > li')
        for movie_sel in movie_items:
            item = MovieItem()
            item['title'] = movie_sel.css('.title::text').extract_first()
            item['score'] = movie_sel.css('.rating_num::text').extract_first()
            item['motto'] = movie_sel.css('.inq::text').extract_first()
            yield item

        hrefs = sel.css('#content > div > div.article > div.paginator > a::attr("href")')
        for href in hrefs:
            full_url = response.urljoin(href.extract())
            yield Request(url=full_url)

# 可以通过下面的命令让爬虫运转起来
scrapy crawl movie

可以在控制台看到爬取到的数据,如果想将这些数据保存到文件中,可以通过-o参数来指定文件名,Scrapy 支持将爬取到的数据导出成 JSON、CSV、XML 等格式

复制代码
scrapy crawl moive -o result.json

通过运行爬虫获得的 JSON 文件中有275条数据,是因为首页被重复爬取了。所以需要不在parse方法中解析获取新页面的 URL,而是通过start_requests方法提前准备好待爬取页面的 URL

复制代码
import scrapy
from scrapy import Selector, Request
from scrapy.http import HtmlResponse

from demo.items import MovieItem

class DoubanSpider(scrapy.Spider):
    name = 'douban'
    allowed_domains = ['movie.douban.com']

    def start_requests(self):
        for page in range(10):
            yield Request(url=f'https://movie.douban.com/top250?start={page * 25}')

    def parse(self, response: HtmlResponse):
        sel = Selector(response)
        movie_items = sel.css('#content > div > div.article > ol > li')
        for movie_sel in movie_items:
            item = MovieItem()
            item['title'] = movie_sel.css('.title::text').extract_first()
            item['score'] = movie_sel.css('.rating_num::text').extract_first()
            item['motto'] = movie_sel.css('.inq::text').extract_first()
            yield item

3、如果希望完成爬虫数据的持久化,可以在数据管道中处理蜘蛛程序产生的Item对象

复制代码
import openpyxl

from demo.items import MovieItem

class MovieItemPipeline:

    def __init__(self):
        self.wb = openpyxl.Workbook()
        self.sheet = self.wb.active
        self.sheet.title = 'Top250'
        self.sheet.append(('名称', '评分', '名言'))

    def process_item(self, item: MovieItem, spider):
        self.sheet.append((item['title'], item['score'], item['motto']))
        return item

    def close_spider(self, spider):
        self.wb.save('豆瓣电影数据.xlsx')

process_item和close_spider都是回调方法(钩子函数), 简单的说就是 Scrapy 框架会自动去调用的方法。当蜘蛛程序产生一个Item对象交给引擎时,引擎会将该Item对象交给数据管道,这时配置好的数据管道的parse_item方法就会被执行,所以可以在该方法中获取数据并完成数据的持久化操作。另一个方法close_spider是在爬虫结束运行前会自动执行的方法

总而言之,数据管道可以完成以下操作:

  • 清理 HTML 数据,验证爬取的数据
  • 丢弃重复的不必要的内容
  • 将爬取的结果进行持久化操作

4、修改settings.py文件对项目进行配置,主要需要修改以下配置

复制代码
# 用户浏览器
USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36'

# 并发请求数量 
CONCURRENT_REQUESTS = 4

# 下载延迟
DOWNLOAD_DELAY = 3
# 随机化下载延迟
RANDOMIZE_DOWNLOAD_DELAY = True

# 是否遵守爬虫协议
ROBOTSTXT_OBEY = True

# 配置数据管道
ITEM_PIPELINES = {
   'demo.pipelines.MovieItemPipeline': 300,
}

# 配置文件中的ITEM_PIPELINES选项是一个字典
# 可以配置多个处理数据的管道,后面的数字代表了执行的优先级,数字小的先执行

49、第四十九天

数据分析:从原始数据中抽取出有价值的信息的这个过程,它是数据科学的重要组成部分。而有价值的信息,可以理解为对数据集处理之后的结果,是从数据集中提炼出的可用于支撑和指导决策的东西

数据分析是通过系统性的收集、整理、处理、检验和解释数据,从中提取有价值的信息、形成结论并支持决策的过程,其核心是利用统计、算法和逻辑方法揭示数据背后的规律、趋势或关联

对于从事数据分析工作的人来说,需要掌握两个部分的技能,一是"数据思维",二是"分析工具"

分析工具部分其实是比较容易掌握的,像 SQL 或 Python 这样的编程语言,大部分人都是可以驾驭的;像 Power BI、Tableau 这样的商业智能工具,通过"拖拉拽"操作就能完成数据的可视化并在此基础上产生商业洞察,上手难度会更低。相反,数据思维部分的内容对大多数的人是不太容易驾驭的,例如"统计思维",在面对实际的业务场景时,却很难将知识映射到业务场景来解决现实的问题。此外,还要去掌握基础的分析方法、理解常用的分析模型,要有相关业务知识的积累才会产生业务洞察发现商业价值,所以思维部分需要不断的在实际业务场景中积累和沉淀

数据分析通用流程

数据分析这个词很多时候可能指的都是狭义的数据分析,这类数据分析主要目标就是生成可视化报表并通过这些报表来洞察业务中的问题,这类工作一般都是具有滞后性的。广义的数据分析还包含了数据挖掘的部分,不仅要通过数据实现对业务的监控和分析,还要利用机器学习算法,找出隐藏在数据背后的知识,并利用这些知识为将来的决策提供支撑,具备一定的前瞻性

数据分析相关库

对于数据分析相关的工作来说,Python是一个非常棒的选择,首先 Python 语言非常容易上手,而且整个 Python 生态圈中,有非常多成熟的用于数据科学的软件包和工具库。不同于其他的数据科学编程语言(如:Julia、R等),Python 除了可以用于数据科学,还能做很多其他的事情

经典的三大神器

  • NumPy:支持常见的数组和矩阵操作,通过ndarray类实现了对多维数组的封装,提供了操作这些数组的方法和函数。由于 NumPy 内置了并行运算功能,当使用多核 CPU 时,NumPy 会自动做并行计算
  • Pandas:pandas 的核心是其特有的数据结构DataFrame和Series,这使得 pandas 可以处理包含不同类型数据的表格和时间序列,这一点是 NumPy 的ndarray做不到的。使用 pandas,可以轻松顺利的加载各种形式的数据,然后对数据进行切片、切块、重塑、清洗、聚合、呈现等操作
  • Matplotlib:matplotlib 是一个包含各种绘图模块的库,能够根据我们提供的数据创建高质量的图表。此外,matplotlib还提供了pylab 模块,这个模块包含了很多像MATLAB一样的绘图组件

其他相关库

  • SciPy:完善了 NumPy 的功能,封装了大量科学计算的算法,包括线性代数、统计检验、稀疏矩阵、信号和图像处理、最优化问题、快速傅里叶变换等
  • Polars:一个高性能的数据分析库,旨在提供比 pandas 更快的数据操作。它支持大规模数据处理,并能够利用多核 CPU 来加速计算,在处理大规模数据集时可以用来替代 pandas
  • Scikit-learn:scikit-learn 最初是 SciPy 的一部分,提供了大量机器学习可能用到的工具,包括数据预处理、监督学习(分类、回归)、无监督学习(聚类)、模式选择、交叉检验等
  • Tensorflow:TensorFlow 是一个开源的深度学习框架,由 Google 开发,主要面向深度学习任务,常用于构建和训练机器学习模型(尤其是复杂的神经网络模型)
  • Keras:Keras 是一个高层次的神经网络 API,主要用于构建和训练深度学习模型。Keras 适合深度学习初学者和研究人员,因为它让构建和训练神经网络变得更加简单
  • PyTorch:PyTorch 是一个开源的深度学习框架,由 Facebook 开发,广泛用于研究和生产环境。PyTorch 是深度学习研究中的热门框架,在计算机视觉、自然语言处理等领域得到了广泛应用
  • NLTK / SpaCy:自然语言处理(NLP)库

50、第五十天

conda命令

如果希望使用 conda 工具来管理依赖项或者创建项目的虚拟环境,可以在终端或命令行提示符中使用 conda 命令。可以在Windows环境下点击"Anaconda Prompt"或"Anaconda PowerShell"来启动支持 conda 的命令行提示符。如果想创建新的虚拟环境或管理三方库(依赖项),直接使用"Anaconda-Navigator"中的"Environments",通过可视化的方式对虚拟环境和依赖项进行管理

1、版本和帮助信息

  • 查看版本:conda -V或conda --version
  • 获取帮助:conda -h或conda --help
  • 相关信息:conda list

2、虚拟环境相关

  • 显示所有虚拟环境:conda env list
  • 创建虚拟环境:conda create --name venv
  • 指定 Python 版本创建虚拟环境:conda create --name venv python=3.7
  • 指定 Python 版本创建虚拟环境并安装指定依赖项:conda create --name venv python=3.7 numpy pandas
  • 通过克隆现有虚拟环境的方式创建虚拟环境:conda create --name venv2 --clone venv
  • 分享虚拟环境并重定向到指定的文件中:conda env export > environment.yml
  • 通过分享的虚拟环境文件创建虚拟环境:conda env create -f environment.yml
  • 激活虚拟环境:conda activate venv
  • 退出虚拟环境:conda deactivate
  • 删除虚拟环境:conda remove --name venv --all

venv和venv2是虚拟环境文件夹的名字,可以进行替换,但是需要使用英文但且不要有特殊字符

3、包(三方库或工具)管理

  • 查看已经安装的包:conda list
  • 搜索指定的包:conda search matplotlib
  • 安装指定的包:conda install matplotlib
  • 更新指定的包:conda update matplotlib
  • 移除指定的包:conda remove matplotlib

说明:在搜索、安装和更新软件包时,默认会连接到官方网站进行操作,如果觉得速度不给力,可以将默认的官方网站替换为国内的镜像网站,推荐使用清华大学的开源镜像网站。将默认源更换为国内镜像的命令是:conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/和conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main。如果需要换回默认源,可以使用命令conda config --remove-key channels

安装和使用JupyterLab

安装和启动

JupyterLab 是下一代的 Notebook,提供了更友好的界面和更强大的功能,十分推荐使用。对于Windows用户来说可以在开始菜单中打开"Anaconda Prompt"或"Anaconda PowerShell",输入jupyter lab命令来启动JupyterLab。对于Python环境来说,可以用 Python 的包管理工具pip来安装 JupyterLab,安装成功后在终端或命令行提示符中执行jupyter lab命令来启动 JupyterLab

复制代码
# 安装 JupyterLab:
pip install jupyterlab

# 安装 Python 数据分析三大神器:
pip install numpy pandas matplotlib

# 启动 JupyterLab:
jupyter lab

JupyterLab 是基于网页的用于交互计算的应用程序,可以用于代码开发、文档撰写、代码运行和结果展示。简单的说,可以在网页中直接编写代码和运行代码,代码的运行结果也会直接在代码块下方进行展示。如在编写代码的过程中需要编写说明文档,可在同一个页面中使用 Markdown 格式进行编写,而且可以直接看到渲染后的效果。此外,Notebook 的设计初衷是提供一个能够支持多种编程语言的工作环境,目前它能够支持超过40种编程语言,包括 Python、R、Julia、Scala 等

首先,可以创建一个用于书写 Python 代码的 Notebook


如果使用 Python 做工程化的项目开发,PyCharm 肯定是最好的选择,它提供了一个集成开发环境应该具有的所有功能,尤其是智能提示、代码补全、自动纠错这类方面十分优秀。如果使用 Python 做数据科学相关的工作,JupyterLab 也是十分优秀的,在数据和图表展示方面 JupyterLab 更加优秀

1、自动补全:在使用 JupyterLab 编写代码时,按Tab键会获得代码提示和补全功能

2、获得帮助:如果希望了解一个对象(如变量、类、函数等)的相关信息或使用方式,可以在对象后面使用?并运行代码, 窗口下方会显示出对应的信息,帮助了解该对象

3、搜索命名:如果只记得一个类或一个函数名字的一部分,可以使用通配符*并配合?进行搜索

4、调用命令:可以在 JupyterLab 中使用!后面跟系统命令的方式来执行系统命令

5、魔法指令:JupyterLab 中有很多魔法指令,例如可以使用%timeit测试语句的执行时间,可以使用%pwd查看当前工作目录等

6、快捷键:JupyterLab 的快捷键可以分为命令模式下的快捷键和编辑模式下的快捷键,所谓编辑模式就是处于输入代码或撰写文档状态的模式,在编辑模式下按Esc可以回到命令模式,在命令模式下按Enter可以进入编辑模式

51、第五十一天

Numpy 是一个开源的 Python 科学计算库,用于快速处理任意维度的数组。Numpy 支持常见的数组和矩阵操作,对于同样的数值计算任务,使用 NumPy代码要简洁的多,而且在性能上面也要远远优于原生 Python,至少是一到两个数量级的差距,而且数据量越大,NumPy 的优势会越明显

NumPy 最为核心的数据类型是ndarray,使用ndarray可以处理一维、二维和多维数组,相当于是一个快速而灵活的大数据容器。NumPy 底层代码使用 C 语言编写,解决了 GIL 的限制,ndarray在存取数据的时候,数据与数据的地址都是连续的,这确保了可以进行高效率的批量操作,性能上远远优于 Python 中的list;另一方面ndarray对象提供了更多的方法来处理数据,尤其获取数据统计特征的方法,这些方法也是 Python 原生的list没有的

准备工作

复制代码
# 1、启动 JupyterLab
jupyter lab


# 2、导入
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

建数组对象

方法一:使用array函数,通过list创建数组对象

复制代码
array1 = np.array([1, 2, 3, 4, 5])
array1
输出:array([1, 2, 3, 4, 5])

array2 = np.array([[1, 2, 3], [4, 5, 6]])
array2
输出:array([[1, 2, 3],
       [4, 5, 6]])

方法二:使用arange函数,指定取值范围和跨度创建数组对象

复制代码
array3 = np.arange(0, 20, 2)
array3

输出:array([ 0,  2,  4,  6,  8, 10, 12, 14, 16, 18])

方法三:使用linspace函数,用指定范围和元素个数创建数组对象,生成等差数列

复制代码
array4 = np.linspace(-1, 1, 11)
array4

输出:array([-1. , -0.8, -0.6, -0.4, -0.2,  0. ,  0.2,  0.4,  0.6,  0.8,  1. ])

方法四:使用logspace函数,生成等比数列

复制代码
array5 = np.logspace(1, 10, num=10, base=2)
array5

注意:等比数列的起始值是2(2的1次幂) ,等比数列的终止值是 1024(2的10次幂),num是元素的个数,base就是底数

输出:array([   2.,    4.,    8.,   16.,   32.,   64.,  128.,  256.,  512., 1024.])

方法五:通过fromstring函数从字符串提取数据创建数组对象

复制代码
array6 = np.fromstring('1, 2, 3, 4, 5', sep=',', dtype='i8')
array6 

输出:array([1, 2, 3, 4, 5])

方法六:通过fromiter函数从生成器(迭代器)中获取数据创建数组对象

复制代码
def fib(how_many):
    a, b = 0, 1
    for _ in range(how_many):
        a, b = b, a + b
        yield a

gen = fib(20)

array7 = np.fromiter(gen, dtype='i8')
array7

输出:array([   1,    1,    2,    3,    5,    8,   13,   21,   34,   55,   89,
        144,  233,  377,  610,  987, 1597, 2584, 4181, 6765])

方法七:使用numpy.random模块的函数生成随机数创建数组对象

复制代码
# 产生 10 个 [0,1) 范围的随机小数
array8 = np.random.rand(10)
array8

# 产生 10 个 [1,100) 范围的随机整数
array9 = np.random.randint(1, 100, 10)
array9

产生 20 个 μ=50 , σ=10 的正态分布随机数
array10 = np.random.normal(50, 10, 20)
array10

# 产生 [0,1) 范围的随机小数构成的 3 行 4 列的二维数组
array11 = np.random.rand(3, 4)
array11

# 产生 [1,100) 范围的随机整数构成的三维数组
array12 = np.random.randint(1, 100, (3, 4, 5))
array12

方法八:创建全0、全1或指定元素的数组

复制代码
# 使用zeros函数
array13 = np.zeros((3, 4))
array13
输出:array([[0., 0., 0., 0.],
            [0., 0., 0., 0.],
            [0., 0., 0., 0.]])

# 使用ones函数
array14 = np.ones((3, 4))
array14
输出:array([[1., 1., 1., 1.],
           [1., 1., 1., 1.],
           [1., 1., 1., 1.]])

# 使用full函数
array15 = np.full((3, 4), 10)
array15
输出:array([[10, 10, 10, 10],
           [10, 10, 10, 10],
           [10, 10, 10, 10]])

方法九:使用eye函数创建单位矩阵

复制代码
np.eye(4)
输出:array([[1., 0., 0., 0.],
           [0., 1., 0., 0.],
           [0., 0., 1., 0.],
           [0., 0., 0., 1.]])

数组对象的属性

size属性:获取数组元素个数

复制代码
array17 = np.arange(1, 100, 2)
array18 = np.random.rand(3, 4)
print(array17.size)
print(array18.size)

输出:
50 
12

shape属性:获取数组的形状

复制代码
print(array17.shape)
print(array18.shape)

输出:
(50,)
(3, 4)

dtype属性:获取数组元素的数据类型

复制代码
print(array17.dtype)
print(array18.dtype)

输出:
int64
float64

ndim属性:获取数组的维度

itemsize属性:获取数组单个元素占用内存空间的字节数

nbytes属性:获取数组所有元素占用内存空间的字节数

ndarray对象元素的数据类型可以参考如下所示的表格

数组的索引运算

NumPy 的ndarray对象可以进行索引和切片操作,通过索引可以获取或修改数组中的元素,通过切片操作可以取出数组的一部分,切片操作也称为切片索引

普通索引

类似于 Python 中list类型的索引运算

java 复制代码
array19 = np.arange(1, 10)
print(array19[0], array19[array19.size - 1])    # 1 9
print(array19[-array20.size], array19[-1])    # 1 9

array20 = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
array20[2]
输出:array([7, 8, 9])

print(array20[0][0])    # 1
print(array20[-1][-1])    # 9
print(array20[1][1])    # 5
print(array20[1, 1])    # 5

array20[1][1] = 10
array20
输出:array([[ 1,  2,  3],
           [ 4, 10,  6],
           [ 7,  8,  9]])

array20[1] = [10, 11, 12]
array20
输出:array([[ 1,  2,  3],
           [10, 11, 12],
           [ 7,  8,  9]])

切片索引

切片索引是形如 **开始索引:结束索引:跨度**的语法,通过指定开始索引(默认值无穷小)、结束索引(默认值无穷大)和跨度(默认值1),从数组中取出指定部分的元素并构成新的数组。因为开始索引、结束索引和步长都有默认值,所以它们都可以省略,如果不指定步长,第二个冒号也可以省略

java 复制代码
array20
array([[ 1,  2,  3],
       [10, 11, 12],
       [ 7,  8,  9]])

array20[:2, 1:]
输出:array([[ 2,  3],
            [11, 12]])

array20[2, :]
输出:array([7, 8, 9])

array20[2:, :]
输出:array([[7, 8, 9]])

array20[:, :2]
输出:array([[ 1,  2],
            [10, 11],
            [ 7,  8]])

array20[::2, ::2]
输出:array([[1, 3],
            [7, 9]])

array20[::-2, ::-2]
输出:array([[9, 7],
            [3, 1]])

二维数组的切片索引

花式索引

花式索引是用保存整数的数组充当一个数组的索引,这里的数组可以是 NumPy 的ndarray,也可以是 Python 中list、tuple等可迭代类型,可以使用正向或负向索引

java 复制代码
array19[[0, 1, 1, -1, 4, -1]]
输出:array([1, 2, 2, 9, 5, 9])

array20[[0, 2]]
输出:array([[1, 2, 3],
             [7, 8, 9]])

array20[[0, 2], [1, 2]]
输出:array([2, 9])

array20[[0, 2], 1]
输出:array([2, 8])

布尔索引

布尔索引就是通过保存布尔值的数组充当一个数组的索引,布尔值为True的元素保留,布尔值为False的元素不会被选中。布尔值的数组可以手动构造,也可以通过关系运算来产生

java 复制代码
array19[[True, True, False, False, True, False, False, True, True]]
输出:array([1, 2, 5, 8, 9])

array19 > 5
输出:array([False, False, False, False, False,  True,  True,  True,  True])

~(array19 > 5)
输出:array([ True,  True,  True,  True,  True, False, False, False, False])
说明:~运算符可以对布尔数组中的布尔值进行逻辑取反,也就是原来的True会变成False,原来的False会变成True

array19[array19 > 5]
输出:array([6, 7, 8, 9])

array19 % 2 == 0
输出:array([False,  True, False,  True, False,  True, False,  True, False])

array19[array19 % 2 == 0]
输出:array([2, 4, 6, 8])

(array19 > 5) & (array19 % 2 == 0)
输出:array([False, False, False, False, False,  True, False,  True, False])
说明:&运算符可以作用于两个布尔数组,如果两个数组对应元素都是True,那么运算的结果就是True,否则就是False,该运算符的运算规则类似于 Python 中的 and 运算符,只不过作用的对象是两个布尔数组

array19[(array19 > 5) & (array19 % 2 == 0)]
输出:array([6, 8])

array19[(array19 > 5) | (array19 % 2 == 0)]
输出:array([2, 4, 6, 7, 8, 9])
说明:|运算符可以作用于两个布尔数组,如果两个数组对应元素都是False,那么运算的结果就是False,否则就是True,该运算符的运算规则类似于 Python 中的 or 运算符,只不过作用的对象是两个布尔数组

array20[array21 % 2 != 0]
输出:array([1, 3, 5, 7, 9])

切片索引虽然创建了新的数组对象,但是新数组和原数组共享了数组中的数据,简单的说,无论通过新数组对象或原数组对象修改数组中的数据,修改的其实是内存中的同一块数据。花式索引和布尔索引也会创建新的数组对象,而且新数组复制了原数组的元素,新数组和原数组并不是共享数据的关系,这一点可以查看数组对象的base属性

通过数组切片处理图像

java 复制代码
# 读入图片创建三维数组对象
guido_image = plt.imread('guido.jpg')
plt.imshow(guido_image)

#对数组的0轴进行反向切片,实现图像的垂直翻转
plt.imshow(guido_image[::-1])

# 对数组的1轴进行反向切片,实现图像的水平翻转
plt.imshow(guido_image[:,::-1])

# 通过切片操作实现抠图,将吉多大叔的头抠出来
plt.imshow(guido_image[30:350, 90:300])

# 通过切片操作实现降采样
plt.imshow(guido_image[::10, ::10])

52、第五十二天

数组对象的方法

获取描述统计信息
描述统计信息主要包括数据的集中趋势、离散程度和频数分析等,其中集中趋势主要看均值和中位数,离散程度可以看极值、方差、标准差等

java 复制代码
array1 = np.random.randint(1, 100, 10)
array1

输出:array([46, 51, 15, 42, 53, 71, 20, 62,  6, 94])

计算总和、均值和中位数

java 复制代码
print(array1.sum())    # 460
print(np.sum(array1))    # 460
print(array1.mean())    # 46.0
print(np.mean(array1))    # 46.0
print(np.median(array1))    # 48.5
print(np.quantile(array1, 0.5))    # 48.5

# 代码中的mean、median和quantile分别是 NumPy 中计算算术平均值、中位数和分位数的函数
# 其中quantitle函数的第二个参数设置为0.5表示计算50%分位数,也就是中位数

极值、全距和四分位距离

java 复制代码
print(array1.max())    # 94
print(np.amax(array1))    # 94
print(array1.min())    # 6
print(np.amin(array1))    # 6
print(np.ptp(array1))    # 88
print(np.ptp(array1))    # 88
q1, q3 = np.quantile(array1, [0.25, 0.75])
print(q3 - q1)    # 34.25

方差、标准差和变异系数

java 复制代码
print(array1.var())    # 651.2
print(np.var(array1))    # 651.2
print(array1.std())    # 25.51862065237853
print(np.std(array1))     # 25.51862065237853
print(array1.std() / array1.mean())    # 0.5547526228777941

绘制箱线图

箱线图又称为盒须图,是显示一组数据分散情况的统计图,因形状如箱子而得名。 它主要用于反映原始数据分布的特征,还可以进行多组数据分布特征的比较

java 复制代码
plt.boxplot(array1, showmeans=True)
plt.ylim([-20, 120])
plt.show()
java 复制代码
值得注意的是,对于二维或更高维的数组,在获取描述统计信息时可以通过名为axis的参数指定均值、方差等运算是沿着哪一个轴来执行,axis参数不同,执行的结果可能是大相径庭的

array2 = np.random.randint(60, 101, (5, 3))
array2
输出:array([[72, 64, 73],
            [61, 73, 61],
            [76, 85, 77],
            [97, 88, 90],
            [63, 93, 82]])

array2.mean()
输出:77.0

array2.mean(axis=0)
输出:array([73.8, 80.6, 76.6])

array2.mean(axis=1)
输出:array([69.66666667, 65.        , 79.33333333, 91.66666667, 79.33333333])

array2.max(axis=0)
输出:array([97, 93, 90])

array2.max(axis=1)
输出:array([73, 73, 85, 97, 93])

需要说明的是,NumPy 的数组对象并没有提供计算几何平均值、调和平均值、去尾平均值等的方法,不过可以使用名为 scipy 的三方库,它的stats模块中提供了这些函数。此外,该模块还提供了计算众数、变异系数、偏态、峰度的函数

java 复制代码
from scipy import stats

print(np.mean(array1))                # 算术平均值:46.0
print(stats.gmean(array1))            # 几何平均值:36.22349548825599
print(stats.hmean(array1))            # 调和平均值:24.497219530825497
print(stats.tmean(array1, [10, 90]))  # 去尾平均值:45.0
print(stats.variation(array1))        # 变异系数:0.5547526228777941
print(stats.skew(array1))             # 偏态系数:0.11644192634527782
print(stats.kurtosis(array1))         # 峰度系数:-0.7106251396024126

其他相关方法概述

1、all() / any()方法:判断数组是否所有元素都是True / 判断数组是否有为True的元素

2、astype()方法:拷贝数组,并将数组中的元素转换为指定的类型

3、reshape()方法:调整数组对象的形状

4、dump()方法:保存数组到二进制文件中,可以通过 NumPy 中的load()函数从保存的文件中加载数据创建数组

java 复制代码
array.dump('array1-data')
array3 = np.load('array1-data', allow_pickle=True)
array3

输出:array([46, 51, 15, 42, 53, 71, 20, 62,  6, 94])

5、tofile()方法:将数组对象写入文件中

6、fill()方法:向数组中填充指定的元素

7、flatten()方法:将多维数组扁平化为一维数组

8、nonzero()方法:返回非0元素的索引

9、round()方法:对数组中的元素做四舍五入操作

10、sort()方法:对数组进行就地排序

11、swapaxes()和transpose()方法:交换数组指定的轴和转置

java 复制代码
array2.swapaxes(0, 1)
输出:array([[1, 4, 7],
             [2, 5, 8],
             [3, 6, 9]])

array2.transpose()
输出:array([[1, 4, 7],
             [2, 5, 8],
             [3, 6, 9]])

12、tolist()方法:将数组转成 Python 中的list

java 复制代码
print(array2.tolist())    # [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
print(type(array2.tolist()))    # <class 'list'>

52、第五十二天

数组的运算

当需要对数组元素进行运算时,不用编写循环代码遍历每个元素,Numpy所有的运算都会自动的矢量化。简单的说就是,NumPy 中的数学运算和数学函数会自动作用于数组中的每个成员

数组跟标量的运算

NumPy 的数组可以跟一个数值进行加、减、乘、除、求模、求幂等运算,对应的运算会作用到数组的每一个元素上

java 复制代码
array1 = np.arange(1, 10)
print(array1 + 10)    # [11 12 13 14 15 16 17 18 19]
print(array1 * 10)    # [10 20 30 40 50 60 70 80 90]

print(array1 > 5)    # [False False False False False  True  True  True  True]
print(array1 % 2 == 0)    # [False  True False  True False  True False  True False]

数组跟数组的运算

NumPy 的数组跟数组也可以执行算术运算和关系运算,运算会作用于两个数组对应的元素上,这就要求两个数组的形状(shape属性)要相同

java 复制代码
array2 = np.array([1, 1, 1, 2, 2, 2, 3, 3, 3])
print(array1 + array2)    # [ 2  3  4  6  7  8 10 11 12]
print(array1 * array2)    # [ 1  2  3  8 10 12 21 24 27]
print(array1 ** array2)    # [  1   2   3  16  25  36 343 512 729]

print(array1 > array2)    # False  True  True  True  True  True  True  True  True]
print(array1 % array2 == 0)    # [ True  True  True  True False  True False False  True]

通用一元函数

NumPy 中通用一元函数的参数是一个数组对象,函数会对数组进行元素级的处理,例如:sqrt函数会对数组中的每个元素计算平方根,而log2函数会对数组中的每个元素计算以2为底的对数

java 复制代码
print(np.sqrt(array1))
print(np.log2(array1))

# [1.         1.41421356 1.73205081 2.         2.23606798 2.44948974
 2.64575131 2.82842712 3.        ]

# [0.         1.         1.5849625  2.         2.32192809 2.5849625
 2.80735492 3.         3.169925  ]

用二元函数

NumPy 中通用二元函数的参数是两个数组对象,函数会对两个数组中的对应元素进行运算,例如:maximum函数会对两个数组中对应的元素找最大值,而power函数会对两个数组中对应的元素进行求幂操作

java 复制代码
array3 = np.array([[4, 5, 6], [7, 8, 9]])
array4 = np.array([[1, 2, 3], [3, 2, 1]])

print(np.maximum(array3, array4))    # [[4 5 6] [7 8 9]]
print(np.power(array3, array4))    # [[  4  25 216] [343  64   9]]

广播机制

两个数组的形状(shape属性)是完全相同的,那么两个形状不同的数组是否可以直接做二元运算或使用通用二元函数进行运算

java 复制代码
array5 = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2], [3, 3, 3]])
array6 = np.array([1, 2, 3])
array5 + array6

#输出: array([[1, 2, 3],
             [2, 3, 4],
             [3, 4, 5],
             [4, 5, 6]])

array7 = np.array([[1], [2], [3], [4]])
array5 + array7

#输出: array([[1, 1, 1],
              [3, 3, 3],
              [5, 5, 5],
              [7, 7, 7]])

形状不同的数组仍然有机会进行二元运算,但这不代表任意形状的数都可以进行二元运算。只有两个数组后缘维度相同或者后缘维度不同但其中一个数组后缘维度为1时,广播机制才会被触发。通过广播机制,NumPy 将两个原本形状不相同的数组变成形状相同,才能进行二元运算,所谓后缘维度,指的是数组形状(shape属性)从后往前看对应的部分


上图中,一个数组的形状是(4, 3),另一个数组的形状是(3, ),从后往前看对应的部分都是3,属于后缘维度相同,可以应用广播机制,第二个数组会沿着缺失元素那个轴的方向去广播自己,最终让两个数组形状达成一致


上图中,一个数组的形状是(3, 4, 2),另一个数组的形状是(4, 2),从后往前看对应的部分都是(4, 2),属于后缘维度相同,可以应用广播机制,第二个数组会沿着缺失元素那个轴的方向去广播自己,最终让两个数组形状达成一致


上图中,一个数组的形状是(4, 3),另一个数组的形状是(4, 1),这是后缘维度不相同的情况,但是第二个数组跟第一个数组不同的地方为1,第二个数组可以沿着为1 的那个轴广播自己,最终让两个数组形状达成一致

除此之外,NumPy 中还提供了很多用于处理数组的函数,ndarray对象的很多方法也可以通过调用函数来实现

1、去重(重复元素只保留一项

java 复制代码
np.unique(array5)

输出:array([0, 1, 2, 3])

2、堆叠和拼接

java 复制代码
array8 = np.array([[1, 1, 1], [2, 2, 2], [3, 3, 3]])
array9 = np.array([[4, 4, 4], [5, 5, 5], [6, 6, 6]])
np.hstack((array8, array9))
# 输出:array([[1, 1, 1, 4, 4, 4],
               [2, 2, 2, 5, 5, 5],
               [3, 3, 3, 6, 6, 6]])

np.vstack((array8, array9))
# 输出:array([[1, 1, 1],
               [2, 2, 2],
               [3, 3, 3],
               [4, 4, 4],
               [5, 5, 5],
               [6, 6, 6]])

np.concatenate((array8, array9))
# 输出:array([[1, 1, 1],
              [2, 2, 2],
              [3, 3, 3],
              [4, 4, 4],
              [5, 5, 5],
              [6, 6, 6]])

np.concatenate((array8, array9), axis=1)
# 输出:array([[1, 1, 1, 4, 4, 4],
              [2, 2, 2, 5, 5, 5],
              [3, 3, 3, 6, 6, 6]])

3、追加和插入元素

java 复制代码
np.append(array1, [10, 100])
# 输出:array([  1,   2,   3,   4,   5,   6,   7,   8,   9,  10, 100])

np.insert(array1, 1, [98, 99, 100])
# 输出:array([  1,  98,  99, 100,   2,   3,   4,   5,   6,   7,   8,   9])

4、抽取和处理元素

java 复制代码
np.extract(array1 % 2 != 0, array1)
# 输出:array([1, 3, 5, 7, 9])

np.select([array1 <= 3, array1 >= 7], [array1 * 10, array1 ** 2])
# 输出:array([10, 20, 30,  0,  0,  0, 49, 64, 81])

说明:上面select函数的第一个参数设置了两个条件,满足第一个条件的元素执行了乘以10的操作,满足第二个条件的元素执行了求平方的操作,两个条件都不能满足的数组元素会被处理为0

np.where(array1 <= 5, array1 * 10, array1 ** 2)
# 输出:array([10, 20, 30, 40, 50, 36, 49, 64, 81])

5、重复数组元素创建新数组

java 复制代码
np.repeat(array1, 3)
# 输出:array([1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9, 9, 9])

np.tile(array1, 2)
# 输出:array([1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7, 8, 9])

6、调整数组大小

java 复制代码
np.resize(array1, (5, 3))
# 输出:array([[1, 2, 3],
              [4, 5, 6],
              [7, 8, 9],
              [1, 2, 3],
              [4, 5, 6]])

提示:array1原本是一个有9个元素的一维数组,通过resize函数调整成为5行3列共15个元素的二维数组,缺少的元素通过复用原数组中的元素来补充

np.resize(array5, (2, 4))
# 输出:array([[0, 0, 0, 1],
              [1, 1, 2, 2]])

7、替换数组元素

java 复制代码
np.put(array1, [0, 1, -1, 3, 5], [100, 200])
array1

# 输出:array([100, 200,   3, 200,   5, 100,   7,   8, 100])
说明:上面put函的第二个参数给出了要被替换的元素的索引,但是用来作为替换值的元素只有100和200,所以这两个值会被循环使用,因此索引为0、1、-1、3、5的元素被依次替换成了100、200、100、200、100

np.place(array1, array1 > 5, [1, 2, 3])
array1

# 输出:array([1, 2, 3, 3, 5, 1, 2, 3, 1])
注意:put函数和place函数都没有返回新的数组对象,而是在原来的数组上直接进行替换

53、第五十三天

Pandas是 Wes McKinney 在2008年开发的一个强大的分析结构化数据的工具集。Pandas 以 NumPy 为基础(实现数据存储和运算),提供了专门用于数据分析的类型、方法和函数,对数据分析和数据挖掘提供了很好的支持。同时 pandas 还可以跟数据可视化工具 matplotlib 很好的整合在一起,非常轻松愉快的实现数据可视化呈现

Pandas 核心的数据类型是Series(数据系列)、DataFrame(数据窗/数据框),分别用于处理一维和二维的数据。除此之外,还有Index的类型及其子类型,为Series和DataFrame提供了索引功能。DataFrame使用得最为广,是因为二维的数据结构刚好可以对应有行有列的表格

创建Series对象

Pandas 库中的Series对象可以用来表示一维数据结构,而且具备索引和一些额外的功能。Series类型的内部结构包含了两个数组,其中一个用来保存数据,另一个用来保存数据的索引,可以通过列表或数组创建Series对象

java 复制代码
import numpy as np
import pandas as pd
ser1 = pd.Series(data=[120, 380, 250, 360], 
                 index=['一季度', '二季度', '三季度', '四季度'])
ser1  # Series构造器中的data参数表示数据,index参数表示数据的索引,相当于数据对应的标签

# 输出:
一季度    120
二季度    380
三季度    250
四季度    360
dtype: int64

通过字典创建Series对象

java 复制代码
ser2 = pd.Series({'一季度': 320, '二季度': 180, '三季度': 300, '四季度': 405})
ser2  # 通过字典创建Series对象时,字典的键就是数据的标签,键对应的值就是数据

Series对象的运算

java 复制代码
标量运算:尝试给刚才的ser1每个季度加上10

ser1 += 10
ser1

矢量运算:尝试把ser1和ser2对应季度的数据加起来

​ser1 + ser2

索引运算

普通索引

与数组一样,Series对象可以进行索引和切片操作,不同于数组的是,Series对象内部维护了一个保存索引的数组,所以除了可以使用整数索引检索数据外,还可以通过设置的索引(标签)获取对应的数据

java 复制代码
#整数索引
ser1[2]    # 250

# 自定义索引
ser1['三季度']    # 250

切片索引

Series对象的切片操作跟列表、数组类似,通过给出起始和结束索引,从Series对象中取出或修改部分数据,也可以使用整数索引和自定义的索引

java 复制代码
ser2[1:3]    # 整数索引

# 输出:
二季度    180
三季度    300
dtype: int64

ser2['二季度':'四季度'] # 自定义索引

# 输出:
二季度    180
三季度    300
四季度    405
dtype: int64

# 在使用自定义索引进行切片时,结束索引对应的元素也是可以取到的

花式索引

java 复制代码
ser2[['二季度', '四季度']]

# 输出:
二季度    400
四季度    405
dtype: int64

布尔索引

java 复制代码
ser2[ser2 >= 500]

# 输出:
二季度    600
三季度    500
dtype: int64

series对象的属性和方法

Series对象的属性和方法非常多

java 复制代码
print(ser2.dtype)                    # 数据类型
print(ser2.hasnans)                  # 有没有空值
print(ser2.index)                    # 索引
print(ser2.values)                   # 值
print(ser2.is_monotonic_increasing)  # 是否单调递增
print(ser2.is_unique)                # 是否每个值都独一无二

统计相关

Series对象支持各种获取描述性统计信息的方法

java 复制代码
print(ser2.count())   # 计数
print(ser2.sum())     # 求和
print(ser2.mean())    # 求平均
print(ser2.median())  # 找中位数
print(ser2.max())     # 找最大
print(ser2.min())     # 找最小
print(ser2.std())     # 求标准差
print(ser2.var())     # 求方差

Series的describe()的方法,可以获得上述所有的描述性统计信息

java 复制代码
ser2.describe()

# 因为describe()返回的也是一个Series对象,所以也可以用ser2.describe()['mean']来获取平均值
# 用ser2.describe()[['max', 'min']]来获取最大值和最小值

如果Series对象有重复的值,可以使用unique()方法获得由独一无二的值构成的数组(去重);可以使用nunique()方法统计不重复值的数量;如果想要统计每个值重复的次数,可以使用value_counts()方法,会返回一个Series对象,它的索引就是原来的Series对象中的值,而每个值出现的次数就是返回的Series对象中的数据,在默认情况下会按照出现次数做降序排列

java 复制代码
ser3 = pd.Series(data=['apple', 'banana', 'apple', 'pitaya', 'apple', 'pitaya', 'durian'])
ser3.value_counts()
# 输出:
apple     3
pitaya    2
durian    1
banana    1
dtype: int64

ser3.nunique()
# 输出: 4

mode()方法来找出数据的众数,因为众数可能不唯一,所以mode()方法的返回值仍然是一个Series对象

处理数据

Series对象的isna()和isnull()方法可以用于空值的判断,notna()和notnull()方法可以用于非空值的判断,Series对象的dropna()和fillna()方法分别用来删除空值和填充空值

java 复制代码
ser4 = pd.Series(data=[10, 20, np.nan, 30, np.nan])
ser4.isna()
# np.nan是一个IEEE 754标准的浮点小数,专门用来表示"不是一个数"
# 在代码中用它来代表空值,也可以用 Python 中的None来表示空值
# 在 pandas 中None也会被处理为np.nan

# 输出:
0    False
1    False
2     True
3    False
4     True
dtype: bool

注意:dropna()和fillna()方法都有一个名为inplace的参数,它的默认值是False,表示删除空值或填充空值不会修改原来的Series对象,而是返回一个新的Series对象。如果将inplace参数的值修改为True,那么删除或填充空值会就地操作,直接修改原来的Series对象,此时方法的返回值是None

Series对象的mask()和where()方法可以将满足或不满足条件的值进行替换

java 复制代码
ser5 = pd.Series(range(5))
ser5.where(ser5 > 0)

# 输出:
0    NaN
1    1.0
2    2.0
3    3.0
4    4.0
dtype: float64

Series对象的duplicated()方法可以找出重复的数据,而drop_duplicates()方法可以删除重复数据。Series对象的apply()和map()方法也是非常重要,可以通过字典或者指定的函数来处理数据,把数据映射或转换成我们想要的样子,这两个方法在数据准备阶段非常重要

java 复制代码
ser6 = pd.Series(['cat', 'dog', np.nan, 'rabbit'])
ser6

ser6.map({'cat': 'kitten', 'dog': 'puppy'})    #通过字典给出的映射规则对数据进行处理
# 输出:
0    kitten
1     puppy
2       NaN
3       NaN
dtype: object

ser6.map('I am a {}'.format, na_action='ignore')    #将指定字符串的format方法作用到数据系列的数据上,忽略掉所有的空值
#输出:
0       I am a cat
1       I am a dog
2              NaN
3    I am a rabbit
dtype: object
java 复制代码
ser7 = pd.Series([20, 21, 12],  index=['London', 'New York', 'Helsinki'])
ser7

ser7.apply(np.square)    # 将求平方的函数作用到数据系列的数据上,也可以将参数np.square替换为lambda x: x ** 2
# 输出:
London      400
New York    441
Helsinki    144
dtype: int64

ser7.apply(lambda x, value: x - value, args=(5, ))
# 输出:
London      15
New York    16
Helsinki     7
dtype: int64

# apply方法中的lambda函数有两个参数:
# 第一个参数是数据系列中的数据,而第二个参数需要传入
# 所以给apply方法增加了args参数,用于给lambda函数的第二个参数传值

取头部值和排序

Series对象的sort_index()和sort_values()方法可以用于对索引和数据的排序,排序方法有一个名为ascending的布尔类型参数,该参数用于控制排序的结果是升序还是降序;而名为kind的参数则用来控制排序使用的算法,默认使用了quicksort,也可以选择mergesort或heapsort;如果存在空值,那么可以用na_position参数空值放在最前还是最后,默认是last

java 复制代码
ser8 = pd.Series(
    data=[35, 96, 12, 57, 25, 89], 
    index=['grape', 'banana', 'pitaya', 'apple', 'peach', 'orange']
)
ser8.sort_values()  # 按值从小到大排序

ser8.sort_index(ascending=False)  # 按索引从大到小排序

如果要从Series对象中找出元素中最大或最小的"Top-N",不需要对所有的值进行排序的,可以使用nlargest()和nsmallest()方法来完成

java 复制代码
ser8.nlargest(3)  # 值最大的3个
ser8.nsmallest(2)  # 值最小的2个

绘制图表

Series对象的plot的方法可以用来生成图表,如果选择生成折线图、饼图、柱状图等,默认会使用Series对象的索引作为横坐标,使用Series对象的数据作为纵坐标

java 复制代码
import matplotlib.pyplot as plt

ser9 = pd.Series({'Q1': 400, 'Q2': 520, 'Q3': 180, 'Q4': 380})
# 通过plot方法的kind指定图表类型为柱状图
ser9.plot(kind='bar')
# 定制纵轴的取值范围
plt.ylim(0, 600)
# 定制横轴刻度(旋转到0度)
plt.xticks(rotation=0)
# 为柱子增加数据标签
for i in range(ser9.size):
    plt.text(i, ser9[i] + 5, ser9[i], ha='center')
plt.show()

# plot方法的kind参数指定了图表类型为饼图
# autopct会自动计算并显示百分比
# pctdistance用来控制百分比到圆心的距离
ser9.plot(kind='pie', autopct='%.1f%%', pctdistance=0.65)
plt.show()

54、第五十四天

DataFrame 是在做数据分析中被使用得最多的类型,可以用来保存和处理异质的二维数据。所谓的"异质"是指DataFrame中每个列的数据类型不需要相同,这也是区别于 NumPy 二维数组的地方。DataFrame提供了极为丰富的属性和方法,帮助实现对数据的重塑、清洗、预处理、透视、呈现等一系列操作

创建DataFrame对象

通过二维数组创建DataFrame对象

复制代码
scores = np.random.randint(60, 101, (5, 3))
courses = ['语文', '数学', '英语']
stu_ids = np.arange(1001, 1006)
df1 = pd.DataFrame(data=scores, columns=courses, index=stu_ids)
df1

# 输出:
        语文  数学  英语
1001    69    80    79
1002    71    60    100
1003    94    81    93
1004    88    88    67
1005    82    66    60

通过字典创建DataFrame对象

复制代码
scores = {
    '语文': [62, 72, 93, 88, 93],
    '数学': [95, 65, 86, 66, 87],
    '英语': [66, 75, 82, 69, 82],
}
stu_ids = np.arange(1001, 1006)
df2 = pd.DataFrame(data=scores, index=stu_ids)
df2

# 输出:
        语文  数学  英语
1001    62    95    66
1002    72    65    75
1003    93    86    82
1004    88    66    69
1005    93    87    82

读取CSV文件创建DataFrame对像

可以通过pandas 模块的read_csv函数来读取 CSV 文件,read_csv函数的参数非常多

  • sep / delimiter:分隔符,默认是,
  • header:表头(列索引)的位置,默认值是infer,用第一行的内容作为表头(列索引)
  • index_col:用作行索引(标签)的列
  • usecols:需要加载的列,可以使用序号或者列名
  • true_values / false_values:哪些值被视为布尔值True / False
  • skiprows:通过行号、索引或函数指定需要跳过的行
  • skipfooter:要跳过的末尾行数
  • nrows:需要读取的行数
  • na_values:哪些值被视为空值
  • iterator:设置为True,函数返回迭代器对象
  • chunksize:配合上面的参数,设置每次迭代获取的数据体量

读取Excel工作表创建DataFrame对象

通过pandas 模块的read_excel函数来读取 Excel 文件,与上面的read_csv非常类似,通过sheet_name参数来指定数据表的名称,但是不同于 CSV 文件,没有sep或delimiter这样的参数

读取关系数据库二维表创建DataFrame对象

pandas模块的read_sql函数可以通过 SQL 语句从数据库中读取数据创建DataFrame对象,该函数的第二个参数代表了需要连接的数据库。对于 MySQL 数据库,可以通过pymysql或mysqlclient来创建数据库连接,得到一个Connection 对象,而这个对象就是read_sql函数需要的第二个参数

复制代码
import pymysql
conn = pymysql.connect(
    host='101.42.16.8', port=3306,
    user='guest', password='Guest.618',
    database='hrs', charset='utf8mb4'
)

df5 = pd.read_sql('select * from tb_emp', conn, index_col='eno')
df5

基本属性和方法

属性名 说明

  • at / iat 通过标签获取DataFrame中的单个值
  • columns DataFrame对象列的索引
  • dtypes DataFrame对象每一列的数据类型
  • empty DataFrame对象是否为空
  • loc / iloc 通过标签获取DataFrame中的一组值
  • ndim DataFrame对象的维度
  • shape DataFrame对象的形状(行数和列数)
  • size DataFrame对象中元素的个数
  • values DataFrame对象的数据对应的二维数组

DataFrame的方法中,首先需要了解info()方法,可以帮助我们了解DataFrame的相关信息,需要查看DataFrame的头部或尾部的数据,可以使用head()或tail()方法,两个方法的默认参数都是5,表示获取DataFrame最前面5行或最后面5行的数据

复制代码
emp_df.info()

emp_df.head()    # 可以获得最前五条数据
emp_df.tail()     # 可以获得最后五条数据

操作数据

单独取DataFrame 的某一行或某一列得到的都是Series对象,事实上,DataFrame对象就是将多个Series对象组合到一起的结果

复制代码
# 获取emp_df的ename列
emp_df.ename
emp_df['ename']

# 如果要获取取出员工编号为2056的员工数据,可以使用整数索引或设置的索引
emp_df.iloc[1]
emp_df.loc[2056]

当然也可以通过花式索引来获取多个行或多个列的数据,花式索引的结果仍然是一个DataFrame对象

复制代码
# 获取多个列:
emp_df[['ename', 'job']]
# 获取多个行:
emp_df.loc[[2056, 7800, 3344]]


# 如果要获取或修改DataFrame 对象某个单元格的数据,需要同时指定行和列的索引
# 例如要获取员工编号为2056的员工的职位信息
emp_df['job'][2056]
emp_df.loc[2056]['job']
emp_df.loc[2056, 'job']

# 推荐使用第三种做法,因为它只做了一次索引运算

当然,也可以通过切片操作来获取多行多列

复制代码
emp_df.loc[2056:3344]

数据筛选

布尔索引:跟ndarray和Series一样,可以通过布尔索引对DataFrame对象进行数据筛选。除了使用布尔索引,DataFrame对象的query方法也可以实现数据筛选,query方法的参数是一个字符串,代表了筛选数据使用的表达式,而且更符合平时的使用习惯

复制代码
emp_df[emp_df.sal > 3500]

emp_df[(emp_df.sal > 3500) & (emp_df.dno == 20)]# 组合多个条件来进行数据筛选

emp_df.query('sal > 3500 and dno == 20')

55、第五十五天

在完成数据加载之后,需要对事实表和维度表进行连接;可能会从不同的数据源加载了结构相同的数据,因此需要将这些数据拼接起来,这种操作称之为数据重塑。当然,拿到的数据质量未必很好的,可能还需要对数据中的缺失值、重复值、异常值进行适当的处理。即便获取的数据在质量上是没有问题的,但也需要对数据进行一系列的预处理,才能满足做数据分析的需求

数据重塑

有的时候,数据分析需要的原始数据可能并不是来自一个地方,实际工作可能需要把不同数据整合到一起。虽然是不同的数据,但数据结构完全一致的话,可以pandas的 concat 函数实现两个或多个DataFrame的数据拼接

接下来使用merge 函数将员工表和部门表的数据合并到一张表中;先使用reset_index方法重新设置all_emp_df 的索引,这样eno 不再是索引而是一个普通列,reset_index方法的inplace参数设置为True表示,重置索引的操作直接在all_emp_df上执行,而不是返回修改后的新对象

复制代码
all_emp_df.reset_index(inplace=True)

pd.merge(all_emp_df, dept_df, how='inner', on='dno')

merge函数的第一个参数代表合并的左表、第二个参数代表合并的右表,DataFrame对象的合并跟数据库中的表连接非常类似,所以代码中的how代表了合并两张表的方式,有left、right、inner、outer四个选项;而on则代表了基于哪个列实现表的合并,相当于 SQL 表连接中的连表条件,如果左右两表对应的列列名不同,可以用left_on和right_on参数取代on参数分别进行指定

数据清洗

通常,从 Excel、CSV 或数据库中获取到的数据并不非常完美,里面可能因为系统或人为的原因混入了重复值或异常值,也可能在某些字段上存在缺失值;再者,DataFrame中的数据也可能存在格式不统一、量纲不统一等各种问题。因此,在开始数据分析之前,对数据进行清洗就显得特别重要

缺失值

可以使用DataFrame对象的isnull 或 isna方法来找出数据表中的缺失值,相对应的,notnull和notna方法可以将非空的值标记为True

复制代码
emp_df.isnull()

emp_df.isna()
# 会将残缺的值(NAN)用布尔值去表达,设置为TRUE,如果是有值的情况下,为FALSE

如果想删除这些缺失值,可以使用DataFrame对象的dropna方法,该方法的axis参数可以指定沿着0轴还是1轴删除,也就是说当遇到空值时,是删除整行还是删除整列,默认是沿0轴进行删除的

复制代码
emp_df.dropna() # 删除包含缺值的行

emp_df.dropna(axis=1) # 删除包含缺值的列

# DataFrame对象的很多方法都有一个名为inplace的参数,该参数的默认值为False
# 表示操作不会修改原来的DataFrame对象,而是将处理后的结果通过一个新的DataFrame对象返回
# 如果将该参数的值设置为True,那么操作就会在原来的DataFrame上面直接修改,方法的返回值为None

在某些特定的场景下,可以对空值进行填充,对应的方法是fillna,填充空值时可以使用指定的值(通过value参数进行指定),也可以用表格中前一个单元格(通过设置参数method=ffill)或后一个单元格(通过设置参数method=bfill)的值进行填充

复制代码
emp_df.fillna(value=0)

# 实际工作中,可能会使用某种统计量(如:均值、众数等)进行填充,或者使用某种插值法(如:随机插值法、拉格朗日插值法等)进行填充,甚至有可能通过回归模型、贝叶斯模型等对缺失数据进行填充

重复值

如果数据表中有重复数据了,可以通过DataFrame对象的duplicated方法判断是否存在重复值,该方法在不指定参数时默认判断行索引是否重复。如果要删除重复值,可以使用drop_duplicates方法,该方法的keep参数可以控制在遇到重复值时,保留第一项还是保留最后一项,或者多个重复项一个都不用保留,全部删除掉

复制代码
dept_df.duplicated('dname')

dept_df.drop_duplicates('dname')

# 将keep参数的值修改为last

dept_df.drop_duplicates('dname', keep='last')

预处理

数据进行预处理,包含了对数据的拆解、变换、归约、离散化等操作;先看一下数据的拆解,如果数据表中的数据是一个时间日期,通常都需要从年、季度、月、日、星期、小时、分钟等维度对其进行拆解,如果时间日期是用字符串表示的,可以先通过pandas的to_datetime函数将其处理成时间日期

复制代码
sales_df = pd.read_excel(
    'data/2020年销售数据.xlsx',
    usecols=['销售日期', '销售区域', '销售渠道', '品牌', '销售额']
)
sales_df.info()

sales_df['月份'] = sales_df['销售日期'].dt.month
sales_df['季度'] = sales_df['销售日期'].dt.quarter
sales_df['星期'] = sales_df['销售日期'].dt.weekday
sales_df

通过日期时间类型的Series对象的dt 属性,获得一个访问日期时间的对象,通过该对象的year、month、quarter、hour等属性,就可以获取到年、月、季度、小时等时间信息,获取到的仍然是一个Series对象,它包含了一组时间信息,所以通常也将这个dt属性称为"日期时间向量"

applymap 和apply 方法常用于数据预处理,Series对象也有apply方法,也用于数据的预处理,但是DataFrame对象还有一个名为transform 的方法,通过传入的函数对数据进行变换,类似Series对象的map方法。需要强调的是,apply方法具有归约效果的,就是能将较多的数据处理成较少的数据或一条数据;而transform方法没有归约效果,只能对数据进行变换,原来有多少条数据,处理后还是有多少条数据

如果要对数据进行深度的分析和挖掘,字符串、日期时间这样的非数值类型都需要处理成数值,因为非数值类型没有办法计算相关性,也没有办法进行X(平方)检验等操作。对于字符串类型,通常可以其分为以下三类,再进行对应的处理

  • 有序变量:字符串表示的数据有顺序关系,那么可以对字符串进行序号化处理
  • 分类变量/ 名义变量:字符串表示的数据没有大小关系和等级之分,那么就可以使用独热编码的方式处理成哑变量(虚拟变量)矩阵
  • 定距变量:字符串本质上对应到一个有大小高低之分的数据,而且可以进行加减运算,那么只需要将字符串处理成对应的数值即可

对于有序变量和定距变量,可以用提到的apply或transform方法来处理,也可以利用scikit-learn中的OrdinalEncoder处理有序变量的字符串。对于分类变量的字符串,可以使用pandas的get_dummies()函数来生成哑变量(虚拟变量)矩阵

复制代码
persons_df = pd.DataFrame(
    data={
        '姓名': ['关羽', '张飞', '赵云', '马超', '黄忠'],
        '职业': ['医生', '医生', '程序员', '画家', '教师'],
        '学历': ['研究生', '大专', '研究生', '高中', '本科']
    }
)
persons_df

# 将职业处理成哑变量矩阵
pd.get_dummies(persons_df['职业'])

# 输出:
    医生 教师  画家  程序员
0	1    0    0    0
1	1    0    0    0
2	0    0    0    1
3	0    0    1    0
4	0    1    0    0

数据离散化,离散化也叫分箱,如果变量的取值是连续值,那么它的取值有无数种可能,在进行数据分组的时候就会非常的不方便,这个时候将连续变量离散化就显得非常重要。之所以把离散化叫做分箱,是因为可以预先设置一些箱子,每个箱子代表了数据取值的范围,这样就可以将连续的值分配到不同的箱子中,从而实现离散化。可以根据分箱的结果对数据进行分组,然后使用聚合函数对每个组进行统计,这是数据分析中经常用到的操作,除此之外,pandas还提供了一个名为qcut的函数,可以指定分位数对数据进行分箱

56、第五十六天

数据透视

当拿到一大堆数据的时候,如何从数据中迅速的解读出有价值的信息,把繁杂的数据变成容易解读的统计图表并再此基础上产生业务洞察,这就是数据分析要解决的核心问题

获取描述性统计信息

首先,通过描述性统计信息,可以了解数据的集中趋势和离散趋势。可以通过DataFrame对象的方法mean、max、min、std、var等方法分别获取每个学生或每门课程的平均分、最高分、最低分、标准差、方差等信息,也可以直接通过describe方法直接获取描述性统计信息

复制代码
scores = np.random.randint(50, 101, (5, 3))
names = ('关羽', '张飞', '赵云', '马超', '黄忠')
courses = ('语文', '数学', '英语')
df = pd.DataFrame(data=scores, columns=courses, index=names)
df

df.mean()    # 计算每门课程成绩的平均分
df.mean(axis=1)   # 计算每个学生成绩的平均分
df.var()    # 计算每门课程成绩的方差
df.describe()    # 获取每门课程的描述性统计信息

排序和取头部值

如果需要对数据进行排序,可以使用DataFrame对象的sort_values方法,该方法的by参数可以指定根据哪个列或哪些列进行排序,而ascending参数可以指定升序或是降序。如果DataFrame数据量很大,排序将非常耗费时间,不过有时候只需要获得排前N名或后N名的数据,这个时候其实没有必要对整个数据进行排序,而是直接利用堆结构找出Top-N的数据。DataFrame的nlargest和nsmallest方法提供对Top-N操作的支持

复制代码
# 将学生表按语文成绩排降序
df.sort_values(by='语文', ascending=False)

df.nlargest(3, '语文')    # 找出语文成绩前3名的学生信息

df.nsmallest(3, '数学')    # 找出数学成绩最低的3名学生的信息

透视表和交叉表

在实际工作中通常把那些行很多列很少的表成为"窄表",如果不想得到这样的一个"窄表",可以使用DataFrame的pivot_table 方法或者是pivot_table 函数来生成透视表。透视表的本质就是对数据进行分组聚合操作,根据 A 列对 B 列进行统计,在pivot_table函数中分别对应index和values参数,这两个参数都可以是单个列或者多个列

复制代码
pd.pivot_table(df, index='销售区域', values='销售额', aggfunc='sum')

# 输出:
销售区域   销售额
     
上海    11610489
北京    12477717
安徽      895463
广东     1617949
江苏     2304380
浙江      687862
福建    10178227

# 上面的结果操作与groupby的方式得到的结果有一些区别,groupby操作后,如果对单个列进行聚合,得到的结果是一个Series对象,而此操作是一个DataFrame 对象

# 如果要统计每个销售区域每个月的销售总额,也可以使用pivot_table函数

df['月份'] = df['销售日期'].dt.month
pd.pivot_table(df, index=['销售区域', '月份'], values='销售额', aggfunc='sum')

#得到的结果是一个DataFrame,但也是一个长长的"窄表",如果希望做成一个行比较少列比较多的"宽表",可以将index参数中的列放到columns参数中

pd.pivot_table(df, index='销售区域', columns='月份', values='销售额', aggfunc='sum', fill_value=0)

# pivot_table函数的fill_value=0会将空值处理为0

交叉表就是一种特殊的透视表,它不需要先构造一个DataFrame对象,而是直接通过数组或Series对象指定两个或多个因素进行运算得到统计结果

复制代码
# 统计每个销售区域的销售总额
sales_area, sales_month, sales_amount = df['销售区域'], df['月份'], df['销售额']

# 使用crosstab函数生成交叉表
pd.crosstab(index=sales_area, columns=sales_month, values=sales_amount, aggfunc='sum').fillna(0).astype('i8')

# 使用了DataFrame对象的fillna方法将空值处理为0,再使用astype方法将数据类型处理成整数

57、第五十七天

Index为Series和DataFrame对象提供了索引服务,有了索引就可以排序数据(sort_index方法)、对齐数据 并实现对数据的快速检索(索引运算)。由于DataFrame类型表示的是二维数据,所以它的行和列都有索引,分别是index和columns。Index类型的创建的比较简单,通常给出data、dtype和name三个参数即可,分别表示作为索引的数据、索引的数据类型和索引的名称。由于Index本身也是一维的数据,索引它的方法和属性跟Series非常类似

范围索引

范围索引是由具有单调性的整数构成的索引,可以通过RangeIndex构造器来创建范围索引,也可以通过RangeIndex类的类方法from_range来创建范围索引

复制代码
sales_data = np.random.randint(400, 1000, 12)
index = pd.RangeIndex(1, 13, name='月份')
ser = pd.Series(data=sales_data, index=index)
ser

分类索引

分类索引是由定类尺度构成的索引。如果需要通过索引将数据分组,然后再进行聚合操作,分类索引就可以派上用场。分类索引还有一个名为reorder_categories的方法,可以给索引指定一个顺序,分组聚合的结果会按照这个指定的顺序进行呈现

复制代码
sales_data = [6, 6, 7, 6, 8, 6]
index = pd.CategoricalIndex(
    data=['苹果', '香蕉', '苹果', '苹果', '桃子', '香蕉'],
    categories=['苹果', '香蕉', '桃子'],
    ordered=True
)
ser = pd.Series(data=sales_data, index=index)
ser

# 基于索引分组数据,然后使用sum进行求和
ser.groupby(level=0).sum()

ser.index = index.reorder_categories(['香蕉', '桃子', '苹果'])
ser.groupby(level=0).sum()

多级索引

Pandas 中的MultiIndex类型用来表示层次或多级索引,可以使用MultiIndex类的类方法from_arrays、from_product、from_tuples等来创建多级索引

间隔索引

间隔索引是使用固定的间隔范围充当索引,通常会使用interval_range函数来创建间隔索引

复制代码
index = pd.interval_range(start=0, end=5)
index

# IntervalIndex([(0, 1], (1, 2], (2, 3], (3, 4], (4, 5]], 
                 dtype='interval[int64, right]')

# contains的方法,可以检查范围内是否包含了某个元素
index.contains(1.5)
# array([False,  True, False, False, False])

# overlaps的方法,可以检查一个范围跟其他的范围是否有重叠
index.overlaps(pd.Interval(1.5, 3.5))
# array([False,  True,  True,  True, False])

# 如果希望间隔范围是左闭右开的状态,可以在创建间隔索引时通过closed='left'来做到
# 如果希望两边都是关闭状态,可以将close参数的值赋值为both

index = pd.interval_range(start=0, end=5, closed='left')
index

index = pd.interval_range(start=pd.Timestamp('2022-01-01'), end=pd.Timestamp('2022-01-04'), closed='both')
index

日期时间索引

DatetimeIndex应该是众多索引中最复杂最重要的一种索引,通常会使用date_range()函数来创建日期时间索引,该函数有几个非常重要的参数start、end、periods、freq、tz,分别代表起始日期时间、结束日期时间、生成周期、采样频率和时区​​​​​​​​​​​​​​

相关推荐
苏子寒1 小时前
Nano-VLLM全代码解析笔记(6)-embed_head和linear
pytorch·笔记·python·机器学习·ai·nlp·vllm
JTaoX1 小时前
PyCharm 连接本地虚拟机完整指南
ide·python·pycharm
Hi_Amos1 小时前
记一次 yfinance 源码调试:SOCKS5 代理下 Chart API 正常,历史数据却一直超时
python·k线·yfinance
青少儿编程课堂2 小时前
用图形化编程做一个“少年探险闯关”小游戏:方向键控制、碰撞检测与多关卡串起完整项目
c++·python·算法·bfs·信息学竞赛
科信集团2 小时前
政策科普|广东省绿色制造体系全解读
学习·制造·绿色
evans在进步2 小时前
Spring Boot 工程化核心详解:Parent、Starter、热部署、事务与多数据源
spring boot·后端·python
迪丽热爱2 小时前
多媒体应用9-823
学习
战略性的菠萝3 小时前
学习笔记-Numpy知识
python·numpy
m4Rk_3 小时前
【论文阅读】Agent 记忆机制(48):Fine-Mem——用细粒度奖励解决长期记忆管理中的奖励稀疏与信用分配
论文阅读·人工智能·学习·开源·github